Compare commits

...
4 Commits
Author SHA1 Message Date
mrleo1nid 02307802bc style(recommender): format demographic integration test
ci / recommender / recommender (push) Successful in 37s
release / release (версия и заметки) (push) Successful in 5s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / db / db (push) Successful in 54s
release / android (APK в релиз) (push) Successful in 3m54s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m38s
2026-10-22 14:25:11 +03:00
mrleo1nid fff91c446b feat(recommendations): collect demographics and advance age cohorts
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
2026-10-22 14:19:52 +03:00
mrleo1nid e158558da3 refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
2026-10-22 13:52:16 +03:00
mrleo1nid c84f90e2cc fix(web): scope playlist creation locator in e2e test
ci / ts / ts (packages + web) (push) Successful in 2m1s
2026-10-22 12:57:25 +03:00
78 changed files with 4128 additions and 2521 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: ci / module boundaries
on:
push:
branches: [main]
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
pull_request:
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
jobs:
boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: node scripts/check-boundaries.mjs
+10
View File
@@ -0,0 +1,10 @@
# Работа с проектом
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+2
View File
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация: [docs/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
```
```bash
+18 -2
View File
@@ -1427,9 +1427,10 @@ paths:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
`required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
responses:
"200":
description: Варианты и состояние
@@ -1819,6 +1820,8 @@ components:
properties:
required:
type: boolean
choice:
$ref: "#/components/schemas/OnboardingChoice"
artists:
type: array
items:
@@ -1854,6 +1857,19 @@ components:
type: object
required: [artist_ids, genres]
properties:
gender:
type: string
nullable: true
enum: [male, female, other]
age_group:
type: string
nullable: true
enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus]
description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
age_group_year:
type: integer
readOnly: true
description: Год последнего выбора возрастной группы.
artist_ids:
type: array
maxItems: 50
+7
View File
@@ -0,0 +1,7 @@
# Android
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
+5
View File
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { OnboardingGate } from '../../ui/Onboarding';
import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/
export default function HomeScreen() {
return <OnboardingGate><WaveHome /></OnboardingGate>;
}
function WaveHome() {
const colors = useColors();
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
+3
View File
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
import { radius, useColors } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { RecommendationSettings } from '../../ui/Onboarding';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog';
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<RecommendationSettings />
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
+40 -41
View File
@@ -1,6 +1,11 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
PlayAttempt,
normalizeGain,
unwrap,
@@ -24,12 +29,7 @@ import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
ready: boolean;
@@ -44,10 +44,6 @@ export interface PlayerState {
wave: WaveInfo | null;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
@@ -84,8 +80,13 @@ class Controller {
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
@@ -308,18 +309,22 @@ class Controller {
if (!this.session) return;
await this.setup();
this.leaveWave();
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
this.update({
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
@@ -332,13 +337,16 @@ class Controller {
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
await this.replaceUpcoming(res.tracks);
if (!this.wavePolicy.isCurrent(version)) return;
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
if (!this.wavePolicy.isCurrent(version)) return;
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
@@ -351,8 +359,8 @@ class Controller {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -360,46 +368,36 @@ class Controller {
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
this.wavePolicy.note(trackId, outcome);
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) {
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.removeUpcomingTracks();
if (!current()) return;
await TrackPlayer.add(native);
if (current()) this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] });
}
this.update({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
@@ -443,6 +441,7 @@ class Controller {
}
async reset() {
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
+85
View File
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { useColors } from '../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
type Options = components['schemas']['Onboarding'];
type Choice = components['schemas']['OnboardingChoice'];
function useOnboarding() {
const session = useSession();
const user = useUser();
const { server } = useSessionCtx();
return useQuery({ queryKey: ['onboarding', server, user.id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: 0, refetchInterval: 30_000 });
}
export function OnboardingGate({ children }: { children: ReactNode }) {
const query = useOnboarding();
const client = useQueryClient();
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
if (!query.data?.required) return <>{children}</>;
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
</ScrollView></Screen>;
}
export function RecommendationSettings() {
const { t } = useTranslation();
const query = useOnboarding();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
</View>;
}
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
})), onSuccess: onDone });
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
}));
return <View style={{ gap: 20 }}>
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
<Muted>{t('onboarding.age_hint')}</Muted>
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
</View>;
}
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
const colors = useColors();
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
const on = selected.includes(item.value);
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
</Pressable>;
})}</View>
</View>;
}
+72
View File
@@ -0,0 +1,72 @@
import type { AuthSession, Track } from '@pipu/core';
import { beforeEach, expect, it, vi } from 'vitest';
vi.mock('@react-native-community/netinfo', () => ({ default: {
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
} }));
vi.mock('../src/lib/prefs', () => ({
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
playerCache: { clearIfRequested: async () => {} },
}));
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
vi.mock('../src/offline/downloads', () => ({
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
sizedCover: (s: string) => s,
}));
vi.mock('react-native-track-player', () => ({
default: {
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
skip: vi.fn().mockResolvedValue(undefined),
},
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
}));
function track(id: string): Track {
return { id, title: id, duration_ms: 1000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
}
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
it('ignores a wave creation response after the player is reset', async () => {
const { player } = await import('../src/player/controller');
const native = (await import('react-native-track-player')).default;
const pending = deferred<ReturnType<typeof reply>>();
const post = vi.fn().mockReturnValue(pending.promise);
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
const start = player.startWave(null, null);
await vi.waitFor(() => expect(post).toHaveBeenCalled());
await player.reset();
pending.resolve(reply('old'));
await start;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue).toEqual([]);
expect(native.add).not.toHaveBeenCalled();
});
it('does not let a late context response replace a manually selected playlist', async () => {
const { player } = await import('../src/player/controller');
const pending = deferred<ReturnType<typeof reply>>();
const session = { client: {
POST: vi.fn().mockResolvedValue(reply('wave')),
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
} };
player.attach(session as unknown as AuthSession, 'http://localhost', null);
await player.startWave(null, null);
const change = player.setWaveContext('focus', null);
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
pending.resolve(reply('stale'));
await change;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
+8
View File
@@ -0,0 +1,8 @@
# Web и desktop UI
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
+4 -1
View File
@@ -10,7 +10,10 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
const playlistsHeader = page.locator('header').filter({
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
});
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
+9
View File
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
await page.getByRole('button', { name: '13–19', exact: true }).click();
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login');
+27
View File
@@ -479,3 +479,30 @@ describe('playlists', () => {
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
describe('recommendation preferences', () => {
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
const admin = makeUser('owner', 'admin');
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
await user.click(screen.getByRole('button', { name: '13–19' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: '20–29' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
await router.navigate({ to: '/' });
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
});
});
@@ -0,0 +1,69 @@
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
}),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
@@ -0,0 +1,56 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { EditDialog } from './EditDialog';
import type { UploadFile } from './common';
afterEach(cleanup);
async function fixture(fail = false) {
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
const requests: unknown[] = [];
const fetchImpl: typeof fetch = async (input) => {
const req = input as Request;
if (req.method === 'PATCH') {
requests.push(await req.json());
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
}
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
};
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
const onSaved = vi.fn();
const onClose = vi.fn();
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
const user = userEvent.setup();
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
return { requests, onSaved, onClose, user };
}
it('saves edited metadata with the target source and closes only after success', async () => {
const f = await fixture();
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
expect(f.onClose).toHaveBeenCalledOnce();
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
});
it('keeps the editor and input when a concurrent review rejects the save', async () => {
const f = await fixture(true);
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
expect(screen.getByRole('dialog')).toBeVisible();
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
expect(f.onSaved).not.toHaveBeenCalled();
expect(f.onClose).not.toHaveBeenCalled();
});
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources } from './queries';
import type { UploadFile } from './common';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useUploadSources();
const save = useMutation({
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { editIcon, refreshIcon } from '../../shared/ui/icons';
import { needsFormatAttention } from './files';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
@@ -0,0 +1,180 @@
import { ApiError } from '@pipu/core';
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
import { BatchCard } from './QueueRows';
import { BatchEditDialog } from './BatchEditDialog';
import { EditDialog } from './EditDialog';
import { RejectDialog } from './RejectDialog';
import { useUploadQueue, useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueue() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useUploadQueue(TABS[tab], tab, onlyPossible);
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: api.retry,
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
@@ -0,0 +1,101 @@
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Settings = components['schemas']['UploadSettings'];
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useUploadSettings();
const sources = useUploadSources();
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return api.updateSettings({
queue_limit_bytes: gb(f.queue), max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null, allow_quality_upgrade: f.upgrade,
});
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label>
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { ApiClient, components, paths } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
/** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) {
return {
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
}),
),
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
),
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
};
}
+84
View File
@@ -0,0 +1,84 @@
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';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
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} 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-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
+27
View File
@@ -0,0 +1,27 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useSession } from '../../app/session';
import { uploadsApi } from './api';
import { MOVING, type UploadStatus } from './common';
export function useUploadsApi() { return uploadsApi(useSession().client); }
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
const api = useUploadsApi();
return useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
}
export function useUploadSources() {
const api = useUploadsApi();
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
}
export function useUploadSettings() {
const api = useUploadsApi();
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
}
+2 -1
View File
@@ -52,7 +52,8 @@ export function HomePage() {
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
staleTime: 0,
refetchInterval: 30_000,
});
if (onboarding.isPending) {
return (
+27 -10
View File
@@ -1,5 +1,5 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,17 +20,19 @@ const MIN_ARTISTS = 3;
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
export function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const [artists, setArtists] = useState<Set<string>>(new Set(options.choice?.artist_ids));
const [genres, setGenres] = useState<Set<string>>(new Set(options.choice?.genres));
const [gender, setGender] = useState(options.choice?.gender ?? null);
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : { artist_ids: [...artists], genres: [...genres], gender, age_group: ageGroup },
}),
),
onSuccess: onDone,
@@ -43,7 +45,7 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
const enough = editing || artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0) || !!gender || !!ageGroup || options.artists.length === 0;
return (
<div className={s.page}>
@@ -52,6 +54,21 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.gender')}>
<h2 className={s.label}>{t('onboarding.gender')}</h2>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!gender} onClick={() => setGender(null)}>{t('onboarding.unspecified')}</button>
{GENDERS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={gender === g} onClick={() => setGender(g)}>{t(`onboarding.genders.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.age_group')}>
<h2 className={s.label}>{t('onboarding.age_group')}</h2>
<p>{t('onboarding.age_hint')}</p>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!ageGroup} onClick={() => setAgeGroup(null)}>{t('onboarding.unspecified')}</button>
{AGE_GROUPS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={ageGroup === g} onClick={() => setAgeGroup(g)}>{t(`onboarding.ages.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
<Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
{t(editing ? 'form.cancel' : 'onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
{enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
+16
View File
@@ -12,6 +12,7 @@ import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -24,6 +25,7 @@ export function SettingsPage() {
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<RecommendationSettings />
<AppearanceSection />
<PasswordSection />
<SessionsSection />
@@ -357,3 +359,17 @@ function SessionsSection() {
</section>
);
}
function RecommendationSettings() {
const { t } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
</section>;
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage';
import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() {
+2 -639
View File
@@ -1,640 +1,3 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UploadQueue } from '../../features/uploads/UploadQueue';
import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
type Settings = components['schemas']['UploadSettings'];
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueuePage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', {
params: {
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
},
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) =>
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
unwrap(
await session.client.POST('/api/v1/admin/uploads/review', {
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
}),
),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
params: { path: { id: batchId } },
body: {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
},
}),
),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
params: { path: { id: file.id } },
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
}),
),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useQuery({
queryKey: ['upload-settings'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
});
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return unwrap(
await session.client.PATCH('/api/v1/admin/settings/uploads', {
body: {
queue_limit_bytes: gb(f.queue),
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
},
}),
);
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label>
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
export function UploadQueuePage() { return <UploadQueue />; }
+1 -84
View File
@@ -1,84 +1 @@
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';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../page.module.css';
import u from './uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
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} 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-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
export * from '../../features/uploads/common';
+31
View File
@@ -0,0 +1,31 @@
export class AudioAnalysis {
constructor(private readonly getAnalyser: () => AnalyserNode | null) {}
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
read = (active: boolean) => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const analyser = this.getAnalyser();
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
}
+28 -19
View File
@@ -1,48 +1,57 @@
import type { AuthSession } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
import { AudioAnalysis } from './analysis';
import { AudioEngine } from './engine';
afterEach(() => vi.restoreAllMocks());
function fixture() {
const player = new Player({} as AuthSession, document.createElement('audio'));
const sample = vi.fn((data: Uint8Array) => {
data.fill(0);
// 48 kHz / 1024: bins 1..4 cover the bass band.
data.fill(255, 1, 5);
});
Object.assign(player, {
state: { ...player.getState(), playing: true },
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
});
return { player, sample };
const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
const analysis = new AudioAnalysis(() => analyser);
return { analysis, sample };
}
describe('audio reactivity', () => {
it('separates bass from the other frequency bands and shares the frame', () => {
vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
player.readAudio();
const { analysis, sample } = fixture();
expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
analysis.read(true);
expect(sample).toHaveBeenCalledTimes(1);
});
it('returns silence after pausing or muting', () => {
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
player.readAudio();
Object.assign(player, { state: { ...player.getState(), playing: false } });
const { analysis, sample } = fixture();
analysis.read(true);
clock.mockReturnValue(120);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
clock.mockReturnValue(140);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(sample).toHaveBeenCalledTimes(1);
});
it('works when Web Audio is unavailable', () => {
const player = new Player({} as AuthSession, document.createElement('audio'));
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
const analysis = new AudioAnalysis(() => null);
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
});
});
describe('audio engine lifetime', () => {
it('removes audio listeners on disposal', () => {
const audio = document.createElement('audio');
const engine = new AudioEngine(audio);
const playing = vi.fn();
engine.on('playing', playing);
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
engine.dispose();
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { AudioAnalysis } from './analysis';
/** Browser audio graph and element lifetime; playback policy belongs to Player/core. */
export class AudioEngine {
readonly audio: HTMLAudioElement;
readonly overlap: HTMLAudioElement;
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
readonly analysis = new AudioAnalysis(() => this.analyser);
private readonly unbind: (() => void)[] = [];
constructor(audio?: HTMLAudioElement) {
this.audio = audio ?? new Audio();
this.overlap = new Audio();
this.audio.preload = this.overlap.preload = 'auto';
}
resume() {
return this.audioContext?.state === 'suspended' ? this.audioContext.resume() : undefined;
}
on<K extends keyof HTMLMediaElementEventMap>(event: K, handler: (event: HTMLMediaElementEventMap[K]) => void) {
this.audio.addEventListener(event, handler);
this.unbind.push(() => this.audio.removeEventListener(event, handler));
}
applyLoudness(level: number) {
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
dispose() {
for (const stop of this.unbind.splice(0)) stop();
for (const audio of [this.audio, this.overlap]) {
audio.pause();
audio.removeAttribute('src');
audio.load();
}
void this.audioContext?.close().catch(() => undefined);
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { Track } from '@pipu/core';
import { desktopHandle } from '../desktop';
import type { PlayerState } from './types';
export interface PlaybackControls {
play(): void; pause(): void; toggle(): void; next(): void; prev(): void; seek(ms: number): void;
}
export class MediaControls {
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private disposed = false;
constructor(
private readonly controls: PlaybackControls,
private readonly getState: () => PlayerState,
private readonly getAudio: () => HTMLAudioElement,
) {
this.bindMediaSession();
this.bindDesktopMedia();
}
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.controls.play();
else if (action === 'pause' || action === 'stop') this.controls.pause();
else if (action === 'toggle') this.controls.toggle();
else if (action === 'next') this.controls.next();
else if (action === 'previous') this.controls.prev();
}).then((stop) => {
if (this.disposed) stop();
else this.desktopUnlisten = stop;
});
}
syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.getState().track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.getState().playing) return;
this.lastDesktop = { id: track.id, playing: this.getState().playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.getState().playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.controls.play());
set('pause', () => this.controls.pause());
set('nexttrack', () => this.controls.next());
set('previoustrack', () => this.controls.prev());
set('seekbackward', (d) => this.controls.seek(this.getState().positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.controls.seek(this.getState().positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.controls.seek(d.seekTime * 1000));
}
updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.getAudio().duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.getAudio().currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
dispose() {
this.disposed = true;
this.desktopUnlisten?.();
this.desktopUnlisten = null;
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
for (const action of ['play', 'pause', 'nexttrack', 'previoustrack', 'seekbackward', 'seekforward', 'seekto'] as const) {
try { ms?.setActionHandler(action, null); } catch { /* Unsupported action. */ }
}
}
}
+127
View File
@@ -0,0 +1,127 @@
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
import type { PlayerState } from './types';
const LOCAL_KEY = 'pipu.player';
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
export interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
export class PlaybackPersistence {
private readonly device = deviceId();
lastServerSave = 0;
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
snapshot(state: PlayerState): Saved {
const ids = state.queue.map((t) => t.id);
const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: state.index - start,
positionMs: state.positionMs,
context: state.context,
volume: state.volume,
shuffle: state.shuffle,
repeat: state.repeat,
savedAt: new Date().toISOString(),
};
}
saveLocal() {
if (!this.getState().track) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.getState().track;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot(this.getState());
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
async restore(): Promise<Saved | null> {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
return saved;
}
dispose() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = null;
}
}
+60 -339
View File
@@ -1,5 +1,9 @@
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
type WaveFeedback,
normalizeGain,
PlayAttempt,
PlayQueue,
@@ -10,80 +14,29 @@ import {
type Track,
} from '@pipu/core';
import { desktopHandle } from '../desktop';
import { MediaControls } from './media';
import type { PlayerState } from './types';
import { AudioEngine } from './engine';
import { PlaybackPersistence } from './persistence';
export type { PlayContext, Track };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
export type { PlayerState } from './types';
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
const WAVE_REFILL_AT = 2;
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
const EARLY_SKIP_MS = 30_000;
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
/**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; }
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
@@ -92,13 +45,17 @@ export class Player {
private context: PlayContext | null = null;
private state: PlayerState;
private readonly listeners = new Set<() => void>();
private readonly device = deviceId();
private lastServerSave = 0;
private readonly persistence: PlaybackPersistence;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
@@ -107,10 +64,7 @@ export class Player {
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.engine = new AudioEngine(audio);
this.events = new EventBuffer({
send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -121,9 +75,9 @@ export class Player {
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
this.persistence = new PlaybackPersistence(session, this.getState);
this.bindAudio();
this.bindMediaSession();
this.bindDesktopMedia();
this.media = new MediaControls(this, this.getState, () => this.audio);
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
@@ -140,13 +94,12 @@ export class Player {
getState = () => this.state;
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private readonly media: MediaControls;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
this.syncDesktopMedia();
this.media.syncDesktopMedia();
}
private syncQueue() {
@@ -183,16 +136,19 @@ export class Player {
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
const version = this.wavePolicy.version;
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
this.wavePolicy.activate(res.session_id);
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false;
this.queue.repeat = 'off';
@@ -210,12 +166,14 @@ export class Player {
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming();
this.queue.append(fresh);
@@ -236,8 +194,8 @@ export class Player {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -245,7 +203,7 @@ export class Player {
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
this.wavePolicy.note(trackId, outcome);
}
/**
@@ -253,29 +211,12 @@ export class Player {
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
*/
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || this.waveLoading) return;
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
if (refresh) this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
this.update({ wave: { ...this.state.wave, fallback: res.fallback } });
this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
this.syncQueue();
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
});
}
toggle() {
@@ -289,7 +230,7 @@ export class Player {
void this.load(true, this.state.positionMs);
return;
}
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
void this.engine.resume()?.catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
}
@@ -402,7 +343,7 @@ export class Player {
// Keep the restored element's buffer when the user presses Play.
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.updateMediaMetadata(track);
this.media.updateMediaMetadata(track);
if (!autoplay) return;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
@@ -418,7 +359,8 @@ export class Player {
void this.refillWave();
if (autoplay) {
try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
const resumed = this.engine.resume();
if (resumed) await resumed;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -465,73 +407,11 @@ export class Player {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
readAudio = () => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const active = this.state.playing && !this.state.loading && !this.state.muted;
const analyser = this.analyser;
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
@@ -590,7 +470,7 @@ export class Player {
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
this.media.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
@@ -599,13 +479,13 @@ export class Player {
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
this.engine.on('playing', () => {
this.update({ playing: true, loading: false });
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
}
});
a.addEventListener('pause', () => {
this.engine.on('pause', () => {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
@@ -614,22 +494,22 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('waiting', () => {
this.engine.on('waiting', () => {
if (this.attempt) this.update({ loading: true });
});
a.addEventListener('timeupdate', () => {
this.engine.on('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.media.updatePositionState();
if (this.state.playing) {
this.maybePreload(ms);
this.maybeCrossfade(ms);
}
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
if (this.state.playing && Date.now() - this.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
this.engine.on('ended', () => {
if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
@@ -640,7 +520,7 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('error', () => void this.onError());
this.engine.on('error', () => void this.onError());
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
@@ -683,138 +563,11 @@ export class Player {
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- Media Session ---
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.play();
else if (action === 'pause' || action === 'stop') this.pause();
else if (action === 'toggle') this.toggle();
else if (action === 'next') this.next();
else if (action === 'previous') this.prev();
}).then((stop) => {
this.desktopUnlisten = stop;
});
}
private syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.state.track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
this.lastDesktop = { id: track.id, playing: this.state.playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.state.playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
// --- сохранение состояния ---
private snapshot(): Saved {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
private saveLocal() { this.persistence.saveLocal(); }
private saveSoon() { this.persistence.saveSoon(); }
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
/** Уход со страницы: дослать события и состояние. */
onHide() {
@@ -828,35 +581,7 @@ export class Player {
*/
async restore() {
const version = ++this.restoreVersion;
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
const saved = await this.persistence.restore();
if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
if (version !== this.restoreVersion) return;
@@ -872,25 +597,21 @@ export class Player {
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!);
this.media.updateMediaMetadata(tracks[idx]!);
// Prepare at the saved position without creating a listening attempt.
await this.load(false, pos);
}
dispose() {
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false;
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
this.engine.dispose();
this.persistence.dispose();
for (const t of this.timers) clearInterval(t);
this.timers = [];
this.desktopUnlisten?.();
this.desktopUnlisten = null;
this.media.dispose();
void this.events.flush();
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
+8 -2
View File
@@ -70,6 +70,7 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
*/
export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }]));
const onboarding = new Map<string, components['schemas']['OnboardingChoice'] | null>();
const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
@@ -206,7 +207,10 @@ export function fakeApi(initial: FakeUser[]) {
case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 });
case 'GET /api/v1/onboarding':
return json({ required: false, artists: [], genres: [] });
return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined });
case 'PUT /api/v1/me/onboarding':
onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']);
return new Response(null, { status: 204 });
case 'GET /api/v1/me/likes':
case 'GET /api/v1/me/dislikes':
case 'GET /api/v1/me/history':
@@ -285,8 +289,10 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null);
return new Response(null, { status: 204 });
}
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
+28 -10
View File
@@ -31,11 +31,11 @@
| Сервис | Язык | Ответственность |
|---|---|---|
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи: сканер внутри процесса, upload-воркеры со статусами в PostgreSQL. |
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, состояния upload/analysis jobs. |
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
@@ -60,7 +60,7 @@
### 3.3 Сканирование библиотеки
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
2. `api` запускает `Scanner.Start` в процессе → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
@@ -94,11 +94,10 @@ pipu-music/
│ │ ├── cmd/api/ # main сервиса
│ │ ├── cmd/storage-agent/ # main агента хранилищ
│ │ ├── internal/
│ │ │ ├── auth/ users/ library/ catalog/ search/
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
│ │ │ ├── auth/ library/ search/ genius/
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ ├── migrations/ # goose SQL-миграции
│ │ └── queries/ # sqlc SQL
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
@@ -120,16 +119,35 @@ pipu-music/
## 5. Архитектурный стиль
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
не лезут в чужие таблицы напрямую.
(операции приложения) → `store` (ручной SQL через pgx). Узкие порты отделяют изменяемые операции
от адаптеров; миграция существующих модулей постепенная. Read-проекции могут делать JOIN между областями.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
реализован Go/Python DTO и обработчиками; отдельных `internal-*.yaml` сейчас нет.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
что важно для офлайн-синхронизации мобильного.
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
## 6. Реализация и планы
Точки изменения после выделения обязанностей:
- `apps/web/src/player/player.ts` — фасад воспроизведения; `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts` — браузерные адаптеры. Типы состояния — `types.ts`.
- `packages/core/src/wave.ts` — общая политика refill/feedback и поколения сессий. Web и Android сохраняют собственные очереди.
- `apps/web/src/features/uploads` — API, queries, экран очереди, диалоги и настройки. Страница очереди только собирает feature.
- `services/api/internal/library`: `sources.go`, `templates.go`, `scans.go`, `schedule.go` — отдельные сценарии; `Service` сохраняет фасад для HTTP и сборки приложения.
- `services/api/internal/uploads`: `placement.go` планирует размещение через узкие порты, `persistence.go` владеет SQL-операциями и транзакциями.
- `services/api/internal/playback/personalizer.go` персонализирует каталог независимо от HTTP; `events.go` принимает события в транзакции.
`pnpm check:boundaries` проверяет новые границы без сборки приложений. Его отдельный CI workflow
запускается при изменении соответствующих областей; read-проекции и composition root не запрещены.
River и sqlc не используются. Сканер и расписание живут в процессе API; upload workers читают
состояния PostgreSQL и пробуждаются каналами/таймерами. Анализатор использует `analysis_jobs`.
Добавление устойчивой очереди или генератора SQL — отдельное архитектурное решение при появлении
соответствующей потребности, не обязательная часть текущего рефакторинга.
+21 -4
View File
@@ -219,10 +219,27 @@ MMR-разнообразие и квоты остаются. Пока модел
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- Онбординг: `GET /onboarding` возвращает 40 исполнителей, 24 жанра, сохранённый `choice` и `required`.
Анкета показывается на главной в вебе/desktop и Android до первого заполнения или пропуска, независимо
от истории и наличия библиотеки. Её можно повторно открыть в настройках профиля.
- `PUT /me/onboarding` сохраняет исполнителей, жанры, необязательный пол (`male`, `female`, `other`) и
возрастную группу (`under_13`, `13_19`, `20_29`, `30_39`, `40_49`, `50_59`, `60_plus`) в
`user_profiles_rec.onboarding`. Пустые списки и `null` для пола/возраста означают пропуск.
- Вместе с исходной возрастной группой сервер сохраняет год выбора (`age_group_year`); клиент не может
подменить его. SQL-функция `rec_age_group` прибавляет прошедшие годы к середине выбранного диапазона
(для открытых диапазонов: 6 и 65 лет). Например, 13–19 лет в 2006 → примерно 30–39 лет в 2026.
API возвращает текущую приблизительную группу. При сохранении той же текущей группы исходная группа
и год остаются прежними; выбор другой группы задаёт новую точку отсчёта. Это оценка, не дата рождения.
- Пока собственных оценок меньше 30, рекомендатель добавляет псевдооценки: исполнители +1, жанры +0.5,
а треки, понравившиеся минимум двум другим незаблокированным слушателям с тем же полом и текущей
возрастной группой, — до +0.25. Если указано одно поле, сравнивается только оно; если ни одного —
демографического сигнала нет. Личные оценки не перезаписываются, дизлайки исключаются.
На каждый источник действует лимит 300 треков. Музыкальные предпочтения пола/возраста заранее не задаются.
- Запасная Go-волна учитывает выбранных исполнителей/жанры и те же демографические когорты после личных
лайков и любимых исполнителей. Случайные треки сохраняют разнообразие.
- Администраторский сброс удаляет профиль целиком, включая все ответы и год выбора. Клиенты обновляют
состояние анкеты каждые 30 секунд, а веб также при возвращении на главную/в окно. Пользователь проходит
настройку снова; текущее воспроизведение не прерывается.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
+184
View File
@@ -0,0 +1,184 @@
# Стоимость работы агентов и план SOLID-рефакторинга — 2026-10-22
## Вывод и границы исходного исследования
Проект уже имеет полезные границы: приложения разделены, есть общий `packages/core`, контракт OpenAPI, интерфейсы auth, отдельный Python-рекомендатель. Основная проблема — не количество технологий, а широкий контекст локальных изменений: несколько крупных оркестраторов смешивают правила, IO и состояние, а часть правил повторяется на клиентах.
Проверены структура tracked-файлов, ключевые Go/TS/Python-компоненты, тесты, CI, документация и два примера изменений из git. Это архитектурное исследование с фокусом на трудоёмкости доработок, а не полный аудит безопасности, производительности или визуального UI. Непрочитанные исходники не считаются проверенными. На этапе исходного исследования код приложения не изменялся и тесты не запускались. Реализация и проверки следующего этапа описаны в конце отчёта.
Фактические токены, стоимость и повторные попытки прошлых сессий не исследованы: в материалах проверки нет соответствующей телеметрии. Ниже — подтверждённые свойства репозитория и объяснение того, как они могут увеличивать работу агента. Процент экономии без замера не установлен.
## Prior Reviews Summary
В `docs/reviews/` найден один предыдущий отчёт: `2026-10-21-review-1.md`. Все 9 задач уже отмечены `[x]`, их повторно не открывали. Открытых задач для переноса: 0. Вновь подтверждённых исправлений ранее открытых задач: 0.
## Измерения
Снимок: HEAD `c84f90e`; локальные посторонние untracked-файлы не включены и не изменены.
- 419 tracked-файлов.
- 260 исходников с расширениями `.go`, `.ts`, `.tsx`, `.py`, `.rs`, исключая каталоги `gen`, `generated` и файлы `.gen.go`: 39 605 строк, включая тесты.
- Из этих исходников 24 файла длиннее 400 строк, 9 — длиннее 600. Длина — сигнал для изучения обязанностей, не самостоятельное нарушение SOLID.
- `services/api/internal/api/gen/api.gen.go`: 11 234 строки.
- `packages/api-client/src/generated/schema.ts`: 4 599 строк.
- `pnpm-lock.yaml`: 8 523 строки.
- Итого эти три файла: 24 356 строк. Их полный вывод в контекст обычно избыточен; это не оценка числа токенов.
- `api/openapi.yaml`: 3 717 строк. Контракт нужен, но обычно достаточно конкретной операции и связанных схем.
Воспроизведение: список из `git ls-files`, подсчёт через `[System.IO.File]::ReadAllLines`, отбор расширений и указанных generated-путей. Это физические строки, без семантического анализа. Наличие файла в git не означает, что агент автоматически загружает его целиком.
## Почему работа агента может быть дорогой
### 1. Недостаточно локальной навигации
В проверенном дереве не найдены `AGENTS.md`; корневой README даёт общие команды, `docs/README.md` — карту продуктовых документов. Нет краткой карты «задача → владельцы кода → инварианты → целевые проверки». Новая сессия вынуждена восстанавливать её поиском.
Нужна короткая корневая инструкция и локальные инструкции по областям, с минимальным пересечением. Большая энциклопедия в корневом AGENTS сама увеличит постоянный контекст. Исключения для generated-кода должны означать «читать точечно при необходимости», а не запрещать проверку контракта.
### 2. Документация описывает частично проектируемую архитектуру как существующую
`docs/architecture.md:34`, `:63`, `:101`, `:123`, `:129` обещают River, sqlc, общение модулей через сервисные интерфейсы и отдельные внутренние YAML-спецификации. В проверенном `services/api/go.mod` нет River/sqlc, сканирование запускает `Scanner.Start` (`library/service.go:614`), SQL находится прямо в store/service, в `api/` найден публичный `openapi.yaml`.
Агенту приходится перепроверять описание. Документ следует разделить на «реализовано» и «планируется», описать действующие очереди/воркеры и не внедрять River/sqlc только ради соответствия старому тексту.
### 3. Слишком широкий контекст плеера
`apps/web/src/player/player.ts:84` — 896 строк: очередь, волна, события, HTMLAudioElement, crossfade, preloading, Web Audio, Media Session, Tauri media, localStorage и серверное восстановление. Изменение одной обязанности требует проверки остальных.
`apps/mobile/src/player/controller.ts:78` — 457 строк с похожей оркестрацией, но другим движком, офлайн-хранилищем и фоновым жизненным циклом. Одинаковые `WAVE_REFILL_AT`, `EARLY_SKIP_MS`, накопление feedback и `refillWave` находятся в обоих клиентах (`web:255`, `mobile:375`). Правку правила нужно сопоставлять в двух реализациях.
### 4. Границы backend непоследовательны
- `uploads/service.go:31–52` зависит от конкретных Store, library.Service и Scanner; `:328` обращается к `s.store.pool.Exec`, `:387` принимает `pgx.Tx` в бизнес-операции.
- `library/service.go:49` объединяет источники, подключение, шаблоны, preview, сканирование, расписание и health-check; всего 829 строк.
- `playback/handler.go:37` объединяет стриминг, персонализацию, события, состояние, историю и реакции; всего 609 строк, использует конкретный `library.Store`.
- `cmd/api/main.go:157–160`: DTO ссылается на playback Handler, а Handler получает DTO через замыкание. Это усложняет сборку и изолированное тестирование. Это не цикл импортов Go.
- `genius/service.go:27` сам создаёт внешний клиент, а также работает с БД и запускает worker. Прямая зависимость от внешнего клиента затрудняет его замену в тесте.
Чтение чужих таблиц в поисковой проекции само по себе допустимо. Главный приоритет — изолировать бизнес-операции и записи; не вводить универсальные репозитории для всех JOIN.
### 5. Проверки не всегда соответствуют границам изменений
Уже есть полезные unit-, integration- и E2E-тесты; CI API и PostgreSQL integration разделены. Но `apps/mobile/test/screens.test.ts` проверяет offlineWave/releaseNeed, а не Controller. В проверенном наборе `apps/mobile/test` нет сценариев жизненного цикла Controller.
`apps/web/src/player/audio.test.ts:15` подменяет внутреннее состояние Player через `Object.assign`. Это конкретный признак неудобной тестовой границы: тесту нужен маленький анализатор, но приходится собирать большой плеер и менять его внутренности.
Без быстрых поведенческих проверок изменяемого модуля агенту требуется больше рассуждений и более широкий прогон. Наличие тестов не доказывает покрытие всех гонок.
### 6. Часть ширины изменений оправдана продуктом
Добавление Genius (`73ad61e`) затронуло 34 файла, включая миграцию, оба клиента, переводы и generated-код. Это сквозная возможность, и само число файлов не доказывает плохую архитектуру. Добавление текущего трека в плейлист (`fb20258`) затронуло 8 файлов, не потребовало переделки backend.
Уменьшать нужно непредвиденную связанность и повторное открытие одних и тех же файлов, сохраняя необходимые контрактные, платформенные и локализационные изменения.
## Phase 1: Code Quality / SOLID
### S — одна причина для изменения
Выделить у веб-плеера `AudioEngine`, `AudioAnalysis`, `PlaybackPersistence`, `MediaControls`. Player сохранить как фасад с текущим публичным API. Сначала вынести наиболее независимые обязанности с сохранением поведения; не переносить весь плеер за один PR.
Разделить library по сценариям: управление источниками, шаблоны/preview, запуск сканов, расписание/проверки доступности. Простое разнесение методов того же Service по файлам улучшит поиск, но не уменьшит связанность без разделения зависимостей.
### O — расширение через ограниченные точки замены
Вынести одинаковую политику волны в `packages/core` как `WaveSession`/`WaveCoordinator`: session generation, feedback, пороги refill и отбрасывание устаревших ответов. Сетевой API передавать через небольшой `WaveGateway`.
Web и Android применяют результат к собственным очередям. Native queue, offline playback и особенности ExoPlayer остаются в Android-адаптере; единый абстрактный движок для всех платформ заранее не нужен.
### L — заменяемость
Доказанных нарушений LSP в исследованной области не обнаружено. Для новых адаптеров проверять одинаковые обещания: порядок результатов, ошибки, отмену и отсутствие воздействия устаревшей сессии. Синхронный HTMLAudioElement и асинхронный native player нельзя объявлять взаимозаменяемыми без явного контракта.
### I — узкие интерфейсы
`auth/store.go:43` объединяет пользователей, refresh-токены, администрирование, audit и настройки; `WithTx` возвращает тот же широкий Store. Это кандидат на последующее разделение по потребителям, но auth уже имеет рабочий интерфейс и тестовый memstore — приоритет ниже плеера/uploads.
Новые границы создавать по реально используемым операциям, например `SourceLookup`, `ScanStarter`, `PlacementCatalog`. Не копировать все методы конкретного Store в интерфейс.
### D — зависимость от операций, а не инфраструктуры
В uploads вынести SQL и pgx-транзакции в persistence-адаптер; сервису дать операции `UpdateMetadata`/`Approve`/`Reject` с согласованными результатами. Сохранить атомарность смены статуса, квот и метаданных решения. Интеграционный тест реальной БД обязателен для изменений транзакций.
Выделить персонализацию каталога из HTTP Handler в самостоятельный объект; DTO и playback получают его при сборке. Конкретные зависимости в composition root (`main.go`, `server/router.go`) нормальны, массовое оборачивание всех конструкторов интерфейсами пользы не даст.
### Performance / Correctness
Новые подтверждённые runtime-дефекты и измеренные проблемы производительности этим исследованием не установлены. Размер SQL-запроса или исходника не считается доказательством медленной работы. Метрики качества рекомендаций и сетевого воспроизведения не измерялись.
## Phase 2: Logical Consistency
Целевой путь backend: HTTP handler → операция приложения → узкие порты → PostgreSQL/файлы/HTTP. Границы транзакций остаются явными. Межмодульные операции используют контракты потребителя; read-модели могут сохранять обоснованные JOIN.
Целевой путь клиента: UI → фасад плеера/feature → общие правила → платформенный адаптер. Для каждого состояния нужен один владелец. На Android владельцем воспроизводимой очереди остаётся native player; общий core не становится второй конкурирующей очередью.
Generated-типы оставить на HTTP-границах. Не создавать копии всех DTO ради формального разделения слоёв. Независимую модель вводить там, где правило использует небольшой стабильный набор полей.
## Phase 3: UI/UX
`apps/web/src/pages/admin/UploadQueuePage.tsx` содержит экран, строку очереди, пакетное редактирование, редактирование файла, отклонение и настройки загрузок (`:30`, `:205`, `:353`, `:422`, `:503`, `:542`), всего 640 строк. Настройки экспортируются из файла страницы очереди, сетевые вызовы находятся в компонентах.
Вынести в `features/uploads`: API/queries, экран очереди, отдельные диалоги, настройки. Странице оставить сборку. Сохранять ключи React Query и правила invalidation; единый большой `useUploadsEverything` лишь переместит проблему.
Новых визуальных и accessibility-дефектов не заявлено: браузерная проверка не проводилась. Закрытые задачи мобильного UI из прошлого ревью не дублировались.
## Порядок внедрения и условия готовности
1. **Навигация и достоверность.** Короткие инструкции по областям, актуальная архитектура, карта локальных проверок. Для типовой задачи понятны 2–5 начальных файлов и команды без чтения всех docs. Это ориентир, а не запрет на более широкий поиск.
2. **Упростить Player, сохранив фасад.** Сначала persistence/media/analysis; отдельно engine/crossfade. Проверки: restore без autoplay, ошибка preload и повтор Play, пауза/seek, очистка обработчиков. Существующие тесты сохранить; новые нужны для поведения на новой границе.
3. **Общая политика волны.** Сначала зафиксировать сценарии, затем подключать web и Android по отдельности. Проверки: поздний ответ после смены сессии, retry feedback, refresh очереди, ранний skip. На устройстве — background playback и offline→online.
4. **Uploads.** Сначала выделить persistence-операции с сохранением транзакций, затем placement и worker-порты. Проверки: конкурирующие решения, квота, повторный import, восстановление после рестарта, очистка staging; результат на реальной БД и файловые побочные эффекты.
5. **Library / playback / catalog.** Разделить use cases и убрать DTO→HTTP Handler из персонализации. Сохранить HTTP-контракт, подписи stream URL и поведение X-Accel. Проверять персонализацию батча, реакции, history/state и fallback wave.
6. **Локализация UI и закрепление границ.** Разделить uploads feature. Добавить автоматическую проверку запрещённых зависимостей с точными исключениями для composition root и проекций. Отложить дробление auth до изменения соответствующей функциональности.
Каждый шаг — отдельное ревьюируемое изменение. Сначала выделение границ без изменения поведения, затем новые возможности. Generated-файлы обновлять штатными генераторами; публичный OpenAPI не дробить до появления измеримой проблемы и готового процесса сборки спецификации.
## Как проверить экономию
Взять сравнимые задачи до/после: изменение порога волны на обоих клиентах, изменение правила размещения upload, добавление поля настроек загрузок. Не сравнивать эти разные задачи между собой; фиксировать модель, режим рассуждения и применённые навыки.
Для каждой записывать input/cached input/output tokens, если доступны; отдельно число прочитанных файлов, объём вывода инструментов, повторные чтения, итерации исправлений, длительность проверок и затронутые модули. Сопоставлять распределение нескольких задач, а не один удачный запуск.
Немедленные меры: `rg` по символам, чтение диапазонов, ограниченный вывод, исключение generated/lock из общего исследования, целевые проверки на промежуточных шагах и необходимые полные проверки перед сдачей. Крупные patch/diff тоже читать сначала без generated, затем отдельно проверять генерацию.
Выбор workflow влияет независимо от кода: полный аудит полезен для такого исследования, но запуск всех его фаз на маленькой правке добавляет работы. Большой каталог навыков, системные инструкции и история чата также могут занимать контекст; их вклад и кэширование не измерены и не устраняются рефакторингом приложения.
## Tasks
Перенесено: 0. Новых задач: 8. Порядок — ожидаемая польза для локальности изменений; сроки и проценты экономии не оценивались.
- [x] 1. [CodeQuality] Добавить короткую карту работы агентов: области, точки входа, источники истины и целевые проверки; новые локальные AGENTS держать компактными — `README.md` line 36.
- [x] 2. [Logic] Привести архитектуру к реализации, явно отделить планы River/sqlc/internal specs — `docs/architecture.md` line 123.
- [x] 3. [SOLID] Сохранить Player как фасад, выделить persistence/media/analysis/audio с независимыми зависимостями и проверками поведения — `apps/web/src/player/player.ts` line 84.
- [x] 4. [SOLID] Вынести общую политику wave/feedback/refill в core, сохранив платформенные очереди и офлайн-адаптацию; покрыть смену сессии и retry — `apps/web/src/player/player.ts` line 255; `apps/mobile/src/player/controller.ts` line 375.
- [x] 5. [SOLID] Убрать SQL/pgx из uploads use cases, выделить атомарные persistence-операции и узкие порты библиотеки/сканера, сохранить квоты и статусные переходы — `services/api/internal/uploads/service.go` line 328.
- [x] 6. [SOLID] Разделить управление источниками, шаблоны и расписание сканов по обязанностям и зависимостям — `services/api/internal/library/service.go` line 49.
- [x] 7. [SOLID] Выделить персонализацию из playback Handler, убрать циклическую сборку DTO через замыкание, постепенно отделить playback use cases от HTTP — `services/api/cmd/api/main.go` line 157; `services/api/internal/playback/handler.go` line 37.
- [x] 8. [CodeQuality] Вынести uploads API/queries, диалоги и настройки в feature-модули с локальными тестами, оставить странице композицию — `apps/web/src/pages/admin/UploadQueuePage.tsx` line 30.
## Исправления — 2026-10-22
Реализованы все 8 задач по коду; внешняя проверка PostgreSQL и физического Android остаётся отдельным условием валидации. Исходные метрики и ссылки выше описывают состояние до исправлений.
1. Добавлены компактные корневой и локальные `AGENTS.md`; README ссылается на карту работы. Общая инструкция не загружает всю документацию.
2. Архитектура описывает реальные pgx-запросы, in-process scanner и статусные upload jobs; River/sqlc/internal YAML отделены от реализации. Добавлена актуальная карта компонентов.
3. Player сохраняет прежний интерфейс, а аудиограф/элементы, анализ, Media Session/Tauri и сохранение выделены в `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts`. Типы состояния отделены. Обработчики аудио и media освобождаются, отложенное сохранение отменяется при dispose. Player: 896 → 617 строк; crossfade и оркестрация очереди остаются в фасаде.
4. `WaveCoordinator` в core обслуживает пороги, feedback и версии запросов на обеих платформах. Поздние ответы/ошибки не меняют новую сессию; смена контекста сохраняет ещё не отправленный feedback. На Android добавлены проверки Controller после reset и ручного выбора списка, native queue остаётся владельцем воспроизведения.
5. SQL и pgx убраны из uploads service/worker. `persistence.go` предоставляет атомарные операции изменения метаданных, решений и завершения импорта; транзакционный механизм Transition сохранён. Выделен PlacementPlanner и порты SourceCatalog/RootResolver/ImportedScanner. Guarded UPDATE теперь сообщает конфликт, если статус успел измениться. Добавлены integration-проверки rollback решения, конкурирующих отказов, quota и повторного завершения импорта; без PostgreSQL они не исполнялись.
6. Library Service стал фасадом над SourceService, TemplateService, ScanService и Scheduler. Зависимости разделены интерфейсами. Результат health-check передаётся через канал, исключая общую запись из timeout и worker веток.
7. Personalizer отделён от HTTP Handler, DTO собирается напрямую без замыкания. Приём событий выделен в EventService; остальные playback-сценарии остаются в существующем handler для постепенного дальнейшего разделения. Unit-тесты проверяют батч персонализации, недоступные треки и ошибки чтения.
8. Страница очереди стала оболочкой из 3 строк; feature содержит экран (181 строка), отдельные диалоги, строки списка, настройки, API и queries. Ключи запросов и invalidation сохранены. Локальные тесты проверяют сохранение метаданных и сохранность формы при конфликте.
Дополнительно: `pnpm check:boundaries` и отдельный CI workflow проверяют введённые границы без сборки всего проекта. Проверка целевая, не заменяет полноценный анализ графа импортов.
### Проверки
- Web typecheck и production build — успешно.
- Web unit/component suite — 51 тест успешно; в том числе restore без autoplay, ошибки preload и новые диалоги.
- Playwright — 10 сценариев успешно: desktop и mobile layouts, очередь/диалоги, навигация и плеер.
- Core — 39 тестов успешно, включая 4 сценария WaveCoordinator.
- Android typecheck — успешно; unit-тесты — 5 успешно, включая 2 сценария Controller с заглушкой native-плеера.
- `go test ./...` и `go vet ./...` — успешно. Новые unit-тесты library/playback проходят.
- Integration build — успешно; `TestMigrationsUpDownUp`, `TestLibraryAndUploads`, `TestPlaylistPersistenceAndIsolation`, `TestUserJourney` явно **SKIP**: не задан `TEST_DATABASE_URL`, Docker daemon недоступен. Результат не подтверждает транзакции на реальной БД.
- Физический Android/background/offline→online не проверялся.
- Публичный OpenAPI и generated-код не менялись. Экономия токенов после рефакторинга ещё не измерена.
+1
View File
@@ -6,6 +6,7 @@
"node": ">=22"
},
"scripts": {
"check:boundaries": "node scripts/check-boundaries.mjs",
"icons": "node scripts/generate-icons.mjs",
"build": "turbo run build",
"lint": "turbo run lint",
+13 -2
View File
@@ -1225,8 +1225,9 @@ export interface paths {
cookie?: never;
};
/**
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
* Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
* @description `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
* `choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
*/
get: operations["getOnboarding"];
put?: never;
@@ -1472,6 +1473,7 @@ export interface components {
};
Onboarding: {
required: boolean;
choice?: components["schemas"]["OnboardingChoice"];
artists: {
/** Format: uuid */
id: string;
@@ -1487,6 +1489,15 @@ export interface components {
}[];
};
OnboardingChoice: {
/** @enum {string|null} */
gender?: "male" | "female" | "other" | null;
/**
* @description Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
* @enum {string|null}
*/
age_group?: "under_13" | "13_19" | "20_29" | "30_39" | "40_49" | "50_59" | "60_plus" | null;
/** @description Год последнего выбора возрастной группы. */
readonly age_group_year?: number;
artist_ids: string[];
genres: string[];
};
+6
View File
@@ -0,0 +1,6 @@
# Общие правила клиентов
- Очередь/учёт событий: `src/player.ts`; волна: `src/wave.ts`; сессия: `src/auth.ts`.
- Не импортируй web, React Native или системные media API. Платформы передают IO через узкие порты.
- Поздние ответы старой wave-сессии не меняют новую; feedback после ошибки возвращается только в ту же сессию.
- `pnpm --filter @pipu/core test`, `pnpm --filter @pipu/core typecheck`; после изменения экспортов проверь web и mobile.
+4
View File
@@ -25,3 +25,7 @@ export { normalizeGain, TARGET_LUFS } from './gain';
export { trackShareUrl } from './share';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
export { AGE_GROUPS, GENDERS } from './onboarding';
+2
View File
@@ -0,0 +1,2 @@
export const AGE_GROUPS = ['under_13', '13_19', '20_29', '30_39', '40_49', '50_59', '60_plus'] as const;
export const GENDERS = ['male', 'female', 'other'] as const;
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { WaveCoordinator, type WaveFeedback } from './wave';
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
describe('wave coordination', () => {
it('preserves unsent feedback when changing context in the same session', async () => {
const next = vi.fn().mockResolvedValue(1);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('track', 'like');
wave.changeContext('a');
await wave.refill(0, true, () => {});
expect(next).toHaveBeenCalledWith('a', [{ track_id: 'track', outcome: 'like' }]);
});
it('ignores late results and allows the new session to refill immediately', async () => {
const old = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('old');
const first = wave.refill(0, false, apply);
wave.activate('new');
await wave.refill(0, false, apply);
old.resolve(1);
await first;
expect(apply.mock.calls.map(([value]) => value)).toEqual([2]);
});
it('retries feedback in order and respects threshold, refresh and single in-flight request', async () => {
const request = deferred<number>();
const next = vi.fn().mockReturnValueOnce(request.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('a');
wave.note('one', 'skip_early');
await wave.refill(3, false, apply);
expect(next).not.toHaveBeenCalled();
const first = wave.refill(3, true, apply);
wave.note('two', 'like');
await wave.refill(0, false, apply);
expect(next).toHaveBeenCalledTimes(1);
request.reject(new Error('offline'));
await first;
await wave.refill(2, false, apply);
expect(next.mock.calls[1]).toEqual(['a', [
{ track_id: 'one', outcome: 'skip_early' }, { track_id: 'two', outcome: 'like' },
] satisfies WaveFeedback[]]);
expect(apply).toHaveBeenCalledTimes(1);
});
it('does not restore old feedback or clear a newer in-flight request after a stale failure', async () => {
const old = deferred<number>();
const fresh = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('old', 'dislike');
const first = wave.refill(0, false, () => {});
wave.activate('a'); // same session, new context
wave.note('new', 'like');
const second = wave.refill(0, false, () => {});
old.reject(new Error('late failure'));
await first;
await wave.refill(0, false, () => {});
expect(next).toHaveBeenCalledTimes(2);
expect(next.mock.calls[1]?.[1]).toEqual([{ track_id: 'new', outcome: 'like' }]);
fresh.resolve(2);
await second;
});
});
+62
View File
@@ -0,0 +1,62 @@
/** Policy shared by web and native players; queue ownership stays with the adapter. */
export const EARLY_SKIP_MS = 30_000;
export const WAVE_REFILL_AT = 2;
export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface WaveFeedback { track_id: string; outcome: WaveOutcome }
export interface WaveGateway<T> {
next(sessionId: string, feedback: WaveFeedback[]): Promise<T>;
}
export class WaveCoordinator<T> {
private sessionId: string | null = null;
private generation = 0;
private pending: WaveFeedback[] = [];
private loading: number | null = null;
constructor(private readonly gateway: WaveGateway<T>) {}
get version() { return this.generation; }
isCurrent(version: number) { return version === this.generation; }
/** Also invalidates in-flight responses when the context changes within a session. */
activate(sessionId: string | null) {
this.sessionId = sessionId;
this.pending = [];
this.loading = null;
return ++this.generation;
}
changeContext(sessionId: string) {
const pending = this.sessionId === sessionId ? this.pending : [];
const version = this.activate(sessionId);
this.pending = pending;
return version;
}
note(trackId: string, outcome: WaveOutcome) {
if (this.sessionId) this.pending.push({ track_id: trackId, outcome });
}
async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise<void>) {
const id = this.sessionId;
const version = this.generation;
if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return;
this.loading = version;
const feedback = this.pending;
this.pending = [];
try {
const result = await this.gateway.next(id, feedback);
if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version));
} catch {
if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending];
} finally {
if (this.loading === version) this.loading = null;
}
}
}
+20 -1
View File
@@ -318,7 +318,26 @@
"pick_more_one": "Pick {{count}} more artist",
"pick_more_few": "Pick {{count}} more artists",
"pick_more_many": "Pick {{count}} more artists",
"pick_more_other": "Pick {{count}} more artists"
"pick_more_other": "Pick {{count}} more artists",
"gender": "Gender",
"age_group": "Age group",
"unspecified": "Prefer not to say",
"edit": "Recommendation preferences",
"age_hint": "Your group will advance approximately from the year selected. You can correct it in your profile.",
"genders": {
"male": "Male",
"female": "Female",
"other": "Other"
},
"ages": {
"under_13": "До 13",
"13_19": "13–19",
"20_29": "20–29",
"30_39": "30–39",
"40_49": "40–49",
"50_59": "50–59",
"60_plus": "60+"
}
},
"playlists": {
"name": "Name",
+20 -1
View File
@@ -318,7 +318,26 @@
"pick_more_one": "Выберите ещё {{count}} исполнителя",
"pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{count}} исполнителей",
"pick_more_other": "Выберите ещё {{count}} исполнителя"
"pick_more_other": "Выберите ещё {{count}} исполнителя",
"gender": "Пол",
"age_group": "Возрастная группа",
"unspecified": "Не указывать",
"edit": "Настроить рекомендации",
"age_hint": "Группа будет обновляться приблизительно с учётом года выбора. Её можно исправить в профиле.",
"genders": {
"male": "Мужской",
"female": "Женский",
"other": "Другой"
},
"ages": {
"under_13": "До 13",
"13_19": "13–19",
"20_29": "20–29",
"30_39": "30–39",
"40_49": "40–49",
"50_59": "50–59",
"60_plus": "60+"
}
},
"playlists": {
"name": "Название",
+47
View File
@@ -0,0 +1,47 @@
import { readdirSync, readFileSync } from 'node:fs';
import { resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
function sources(dir, extension) {
return readdirSync(resolve(root, dir), { withFileTypes: true }).flatMap((entry) => {
const path = `${dir}/${entry.name}`;
return entry.isDirectory() ? sources(path, extension) : path.endsWith(extension) ? [path] : [];
});
}
// Check the boundaries introduced by the refactor. Existing query projections and
// composition roots are deliberately outside these use-case-specific rules.
const rules = [
{
files: sources('packages/core/src', '.ts'),
forbidden: /(?:from\s*|import\s*)['"](?:[^'"]*apps\/|(?:@react-native[^'"]*|react(?:-native)?(?:[-\/]|['"]))|expo(?:[-\/]|['"])|\.\.[^'"]*(?:web|mobile|desktop)\/)/,
message: 'core must not import platform/UI implementations',
},
{
files: ['service.go', 'worker.go', 'placement.go', 'batch.go'].map((p) => `services/api/internal/uploads/${p}`),
forbidden: /github\.com\/jackc\/pgx|\.store\.pool\b|\b(?:tx|pool)\.(?:Exec|Query|QueryRow)\(/,
message: 'upload SQL and pgx belong in store/persistence adapters',
},
{
files: sources('apps/web/src/features/uploads', '.tsx').filter((p) => !p.endsWith('/common.tsx')),
forbidden: /\b(?:session|client)\.(?:GET|POST|PUT|PATCH|DELETE)\(/,
message: 'upload views must use feature API/queries',
},
];
const errors = [];
for (const rule of rules) {
for (const path of rule.files) {
const text = readFileSync(resolve(root, path), 'utf8');
text.split(/\r?\n/).forEach((line, i) => {
if (rule.forbidden.test(line)) errors.push(`${relative(root, resolve(root, path))}:${i + 1}: ${rule.message}`);
});
}
}
if (errors.length) {
console.error(errors.join('\n'));
process.exitCode = 1;
} else {
console.log('Module boundary checks passed.');
}
+9
View File
@@ -0,0 +1,9 @@
# Go API
- Сборка зависимостей: `cmd/api/main.go`; маршрутизация: `internal/server/router.go`. Конкретные адаптеры здесь допустимы.
- HTTP-типы: `internal/api/gen`; исходный контракт: корневой `api/openapi.yaml`. Генерация: `go generate ./...`.
- Области: `auth`, `library`, `uploads`, `playback`, `wave`, `search`, `genius`. Начинай с handler → операция → store затронутого сценария.
- SQL и pgx-транзакции принадлежат persistence-адаптерам. Смена статуса upload, reviewer metadata и квота должны сохранять атомарность. Read-проекции могут объединять таблицы модулей.
- Целевая проверка: `go test ./internal/<module>/...`; перед сдачей Go-рефакторинга: `go test ./...`, `go vet ./...`.
- PostgreSQL integration: `TEST_DATABASE_URL` и `go test -tags integration ./internal/dbtest ./internal/playback`. E2E API: `go test -tags integration ./cmd/api` (проверь заголовок теста/переменные окружения).
- Сканер работает внутри процесса; upload jobs используют статусы PostgreSQL и worker loops. River/sqlc сейчас не используются.
+27 -2
View File
@@ -29,6 +29,7 @@ import (
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/config"
@@ -281,17 +282,41 @@ func TestUserJourney(t *testing.T) {
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
var onb struct {
Required bool `json:"required"`
Choice *gen.OnboardingChoice `json:"choice"`
Required bool `json:"required"`
Artists []struct {
ID string `json:"id"`
} `json:"artists"`
}
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.NotEmpty(t, onb.Artists)
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil)
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}, "gender": "male", "age_group": "13_19", "age_group_year": 1900}, nil)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.False(t, onb.Required)
require.NotNil(t, onb.Choice)
require.Equal(t, gen.Male, *onb.Choice.Gender)
require.Equal(t, gen.N1319, *onb.Choice.AgeGroup)
require.Greater(t, *onb.Choice.AgeGroupYear, 1900, "client cannot forge the selection year")
_, err = pool.Exec(ctx, `UPDATE user_profiles_rec SET onboarding = jsonb_set(onboarding,
'{age_group_year}', to_jsonb(EXTRACT(YEAR FROM CURRENT_DATE)::int - 20)) WHERE user_id = $1`, tokens.User.ID)
require.NoError(t, err)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup, "13–19 twenty years ago is approximately 30–39 now")
originalYear := *onb.Choice.AgeGroupYear
c.ok("PUT", "/api/v1/me/onboarding", onb.Choice, nil)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.Equal(t, originalYear, *onb.Choice.AgeGroupYear, "editing tastes preserves the age anchor")
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup)
c.ok("POST", "/api/v1/admin/users/"+tokens.User.ID+"/reset-recommendations", nil, nil)
onb.Choice = nil
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.True(t, onb.Required)
require.Nil(t, onb.Choice, "all onboarding answers are cleared")
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{}, "genres": []string{}}, nil)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.False(t, onb.Required, "skip is remembered")
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
var wave struct {
SessionID string `json:"session_id"`
+4 -4
View File
@@ -154,10 +154,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
}
return libSvc.Root(src)
}
var dto library.DTO
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
pbStore := playback.NewStore(pool)
dto := library.DTO{Signer: signer, Personal: playback.NewPersonalizer(pbStore, signer)}
pb := playback.NewHandler(pbStore, libStore, dto, signer, transcoder,
rootFor, cfg.XAccel, log)
dto = library.DTO{Signer: signer, Personal: pb}
libSvc.OnCatalogChanged = indexer.Trigger
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
go libSvc.Restore(ctx)
@@ -169,7 +169,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
upSvc.Run(ctx)
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Genius: genius.NewHandler(geniusSvc),
+321 -285
View File
@@ -53,6 +53,24 @@ const (
HealthStatusStatusOk HealthStatusStatus = "ok"
)
// Defines values for OnboardingChoiceAgeGroup.
const (
N1319 OnboardingChoiceAgeGroup = "13_19"
N2029 OnboardingChoiceAgeGroup = "20_29"
N3039 OnboardingChoiceAgeGroup = "30_39"
N4049 OnboardingChoiceAgeGroup = "40_49"
N5059 OnboardingChoiceAgeGroup = "50_59"
N60Plus OnboardingChoiceAgeGroup = "60_plus"
Under13 OnboardingChoiceAgeGroup = "under_13"
)
// Defines values for OnboardingChoiceGender.
const (
Female OnboardingChoiceGender = "female"
Male OnboardingChoiceGender = "male"
Other OnboardingChoiceGender = "other"
)
// Defines values for PlayEventContextType.
const (
PlayEventContextTypeAlbum PlayEventContextType = "album"
@@ -603,6 +621,7 @@ type Onboarding struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
} `json:"artists"`
Choice *OnboardingChoice `json:"choice,omitempty"`
Genres []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
@@ -612,10 +631,22 @@ type Onboarding struct {
// OnboardingChoice defines model for OnboardingChoice.
type OnboardingChoice struct {
ArtistIds []openapi_types.UUID `json:"artist_ids"`
Genres []string `json:"genres"`
// AgeGroup Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
AgeGroup *OnboardingChoiceAgeGroup `json:"age_group"`
// AgeGroupYear Год последнего выбора возрастной группы.
AgeGroupYear *int `json:"age_group_year,omitempty"`
ArtistIds []openapi_types.UUID `json:"artist_ids"`
Gender *OnboardingChoiceGender `json:"gender"`
Genres []string `json:"genres"`
}
// OnboardingChoiceAgeGroup Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
type OnboardingChoiceAgeGroup string
// OnboardingChoiceGender defines model for OnboardingChoice.Gender.
type OnboardingChoiceGender string
// PathPattern defines model for PathPattern.
type PathPattern struct {
IsEnabled bool `json:"is_enabled"`
@@ -1882,7 +1913,7 @@ type ServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
// (GET /api/v1/onboarding)
GetOnboarding(w http.ResponseWriter, r *http.Request)
// Страница поиска
@@ -2355,7 +2386,7 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i
w.WriteHeader(http.StatusNotImplemented)
}
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
// (GET /api/v1/onboarding)
func (_ Unimplemented) GetOnboarding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
@@ -8304,7 +8335,7 @@ type StrictServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
// (GET /api/v1/onboarding)
GetOnboarding(ctx context.Context, request GetOnboardingRequestObject) (GetOnboardingResponseObject, error)
// Страница поиска
@@ -10879,286 +10910,291 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"hIU8hV6vce/Wvf8bAAD//7F7c4hPbwEA",
"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",
"7iBqh3IBAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -227,12 +228,77 @@ func TestLibraryAndUploads(t *testing.T) {
assert.Empty(t, stale, "only uploading files expire")
up.Audit(ctx, &author, "upload.test", fileID, map[string]any{"ok": true})
// Импорт: связь с файлом библиотеки, место освобождено.
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusPendingReview}, uploads.StatusApproved, nil))
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting, nil))
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusImporting}, uploads.StatusImported,
func(tx pgx.Tx) error { return uploads.MarkImported(ctx, tx, fileID, fileIDs[1]) }))
// A failure writing reviewer metadata must roll back both status and quota.
require.Error(t, up.Decide(ctx, fileID, uuid.New(), false, "invalid reviewer"))
f, err = up.File(ctx, fileID)
require.NoError(t, err)
assert.Equal(t, uploads.StatusPendingReview, f.Status)
assert.Nil(t, f.ReviewedBy)
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
assert.EqualValues(t, 400, used)
// Import operations retain the status/reason/link and quota invariants.
require.NoError(t, up.Decide(ctx, fileID, author, true, "discard on approval"))
f, err = up.File(ctx, fileID)
require.NoError(t, err)
require.NotNil(t, f.ReviewedBy)
assert.Equal(t, author, *f.ReviewedBy)
assert.Nil(t, f.StatusReason)
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
require.NoError(t, up.FailImport(ctx, fileID, "retryable failure"))
f, err = up.File(ctx, fileID)
require.NoError(t, err)
assert.Equal(t, uploads.StatusImportFailed, f.Status)
require.NotNil(t, f.StatusReason)
assert.Equal(t, "retryable failure", *f.StatusReason)
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusImportFailed}, uploads.StatusApproved))
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1]))
require.ErrorIs(t, up.CompleteImport(ctx, fileID, src.ID, "ignored.flac", fileIDs[1]), uploads.ErrWrongStatus)
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
assert.EqualValues(t, 0, used)
// Two reviewers cannot both decide the same pending file or release quota twice.
secondHash := sha256.Sum256([]byte("concurrent decision"))
_, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]},
}, settings)
require.NoError(t, err)
require.Len(t, reserved, 1)
raceID := reserved[0].FileID
require.NotEqual(t, uuid.Nil, raceID)
require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusUploading}, uploads.StatusProcessing))
require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview))
var wg sync.WaitGroup
results := make(chan error, 2)
for _, reason := range []string{"first", "second"} {
wg.Add(1)
go func(reason string) { defer wg.Done(); results <- up.Decide(ctx, raceID, author, false, reason) }(reason)
}
wg.Wait()
close(results)
success, conflicts := 0, 0
for err := range results {
if err == nil {
success++
} else {
require.ErrorIs(t, err, uploads.ErrWrongStatus)
conflicts++
}
}
assert.Equal(t, 1, success)
assert.Equal(t, 1, conflicts)
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
assert.Zero(t, used)
f, err = up.File(ctx, raceID)
require.NoError(t, err)
assert.Equal(t, uploads.StatusRejected, f.Status)
require.NotNil(t, f.ReviewedBy)
assert.Equal(t, author, *f.ReviewedBy)
require.NotNil(t, f.StatusReason)
assert.Contains(t, []string{"first", "second"}, *f.StatusReason)
}
+69
View File
@@ -0,0 +1,69 @@
package library
import (
"context"
"errors"
"net/http"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
type ScanStore interface {
GetSource(context.Context, uuid.UUID) (Source, error)
RetryFailedAnalysis(context.Context) (int, error)
}
type ScanStarter interface {
Start(Source, string, bool) error
}
// ScanService owns explicit and scheduled scan starts.
type ScanService struct {
store ScanStore
scanner ScanStarter
board *ProgressBoard
root func(Source) (string, error)
}
// StartScan запускает скан источника в фоне.
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
// не был смонтирован).
func (s *ScanService) RetryAnalysis(ctx context.Context) (int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return 0, err
}
return s.store.RetryFailedAnalysis(ctx)
}
func (s *ScanService) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return Progress{}, notFound(err)
}
if err := s.startScan(src, full); err != nil {
return Progress{}, err
}
p, _ := s.board.Get(id)
return p, nil
}
func (s *ScanService) startScan(src Source, full bool) error {
if src.Status != "mounted" {
return apperr.New(http.StatusConflict, CodeSourceOffline, "source is not connected")
}
root, err := s.root(src)
if err != nil {
return err
}
if err := s.scanner.Start(src, root, full); errors.Is(err, ErrScanRunning) {
return apperr.Conflict(CodeScanRunning, "scan is already running")
} else if err != nil {
return err
}
return nil
}
@@ -0,0 +1,55 @@
package library
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
type scanStoreStub struct{ source Source }
func (s scanStoreStub) GetSource(context.Context, uuid.UUID) (Source, error) { return s.source, nil }
func (s scanStoreStub) RetryFailedAnalysis(context.Context) (int, error) { return 7, nil }
type scanStartFunc func(Source, string, bool) error
func (f scanStartFunc) Start(s Source, root string, full bool) error { return f(s, root, full) }
func TestScanServiceAuthorizationAndStart(t *testing.T) {
id := uuid.New()
called := false
s := &ScanService{
store: scanStoreStub{Source{ID: id, Status: "mounted"}}, board: NewProgressBoard(),
root: func(Source) (string, error) { return "music", nil },
scanner: scanStartFunc(func(src Source, root string, full bool) error {
called = true
require.Equal(t, id, src.ID)
require.Equal(t, "music", root)
require.True(t, full)
return ErrScanRunning
}),
}
_, err := s.StartScan(context.Background(), id, true)
require.Error(t, err)
require.False(t, called)
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
_, err = s.StartScan(ctx, id, true)
var problem *apperr.Error
require.ErrorAs(t, err, &problem)
require.Equal(t, CodeScanRunning, problem.Code)
require.True(t, called)
}
func TestScanServiceDoesNotStartOfflineOrUnresolvedSource(t *testing.T) {
s := &ScanService{} // no scanner needed: errors must be returned before IO
require.Error(t, s.startScan(Source{Status: "error"}, false))
failure := errors.New("invalid root")
s.root = func(Source) (string, error) { return "", failure }
require.ErrorIs(t, s.startScan(Source{Status: "mounted"}, false), failure)
}
+118
View File
@@ -0,0 +1,118 @@
package library
import (
"context"
"errors"
"log/slog"
"time"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
)
type ScheduleStore interface {
ListSources(context.Context) ([]Source, error)
SetSourceStatus(context.Context, uuid.UUID, string, *string) error
}
type sourceConnection interface {
connect(context.Context, Source) (Source, error)
check(context.Context, Source, bool) (string, *string)
}
type scheduledScan interface {
startScan(Source, bool) error
}
// Scheduler owns the periodic lifetime, not source or template business rules.
type Scheduler struct {
store ScheduleStore
sources sourceConnection
scans scheduledScan
log *slog.Logger
}
// Restore при старте api переподключает NFS-хранилища и проверяет local (после перезапуска агента).
func (s *Scheduler) Restore(ctx context.Context) {
sources, err := s.store.ListSources(ctx)
if err != nil {
s.log.Error("restore sources", "err", err)
return
}
for _, src := range sources {
if src.Status == "disabled" {
continue
}
if _, err := s.sources.connect(ctx, src); err != nil {
s.log.Error("restore source", "source_id", src.ID, "err", err)
}
}
}
// RunSchedule раз в минуту проверяет доступность хранилищ и запускает сканы по расписанию.
func (s *Scheduler) RunSchedule(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
sources, err := s.store.ListSources(ctx)
if err != nil {
s.log.Warn("schedule: list sources", "err", err)
continue
}
for _, src := range sources {
if src.Status == "disabled" {
continue
}
s.healthCheck(ctx, src)
if src.ScanInterval == nil || src.Status != "mounted" {
continue
}
if src.LastScanAt == nil || time.Since(*src.LastScanAt) >= *src.ScanInterval {
if err := s.scans.startScan(src, false); err != nil && !errors.Is(err, apperr.Conflict(CodeScanRunning, "")) {
s.log.Warn("scheduled scan", "source_id", src.ID, "err", err)
}
}
}
}
}
// healthCheck: недоступное хранилище — status=error (треки серые), вернулось — снова mounted.
func (s *Scheduler) healthCheck(ctx context.Context, src Source) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
type result struct {
status string
message *string
}
done := make(chan result, 1)
go func() {
status, message := s.sources.check(ctx, src, false)
done <- result{status, message}
}()
var status string
var message *string
select {
case r := <-done:
status, message = r.status, r.message
case <-ctx.Done():
m := "storage does not respond"
status, message = "error", &m
}
if status == "error" && src.Type == SourceNFS && src.Status == "error" {
// Попробовать перемонтировать, как при старте.
if _, err := s.sources.connect(ctx, src); err == nil {
return
}
}
if status != src.Status || deref(message) != deref(src.StatusMessage) {
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
s.log.Warn("health check", "source_id", src.ID, "err", err)
}
}
}
+13 -772
View File
@@ -3,25 +3,10 @@ package library
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/storageagent"
)
// Коды ошибок библиотеки.
@@ -45,24 +30,23 @@ type Config struct {
HostRoot string // LIBRARY_HOST внутри контейнера, для local-источников
}
// Service управляет хранилищами и сканами.
// Service preserves the API while each use case owns only its dependencies.
type Service struct {
store *Store
scanner *Scanner
board *ProgressBoard
agent Agent
prober Prober
cfg Config
log *slog.Logger
mu sync.Mutex // сериализует переподключение одного и того же источника
// OnCatalogChanged — каталог изменился не сканом (удалено хранилище): переиндексация поиска.
OnCatalogChanged func()
*SourceService
*TemplateService
*ScanService
*Scheduler
}
// NewService создаёт сервис.
func NewService(store *Store, scanner *Scanner, board *ProgressBoard, agent Agent, prober Prober, cfg Config, log *slog.Logger) *Service {
return &Service{store: store, scanner: scanner, board: board, agent: agent, prober: prober, cfg: cfg, log: log}
sources := &SourceService{store: store, board: board, agent: agent, cfg: cfg, log: log}
scans := &ScanService{store: store, scanner: scanner, board: board, root: sources.Root}
return &Service{
SourceService: sources,
TemplateService: &TemplateService{store: store, prober: prober, root: sources.Root},
ScanService: scans,
Scheduler: &Scheduler{store: store, sources: sources, scans: scans, log: log},
}
}
func forbidden() error { return apperr.Forbidden(auth.CodeForbidden, "not allowed") }
@@ -84,746 +68,3 @@ func notFound(err error) error {
}
return err
}
// Root — каталог источника в файловой системе api.
func (s *Service) Root(src Source) (string, error) {
switch src.Type {
case SourceLocal:
rel := ""
if src.LocalPath != nil {
rel = *src.LocalPath
}
return s.hostPath(rel)
case SourceNFS:
return filepath.Join(s.cfg.LibraryRoot, src.ID.String()), nil
}
return "", fmt.Errorf("library: unknown source type %q", src.Type)
}
// hostPath — путь внутри LIBRARY_HOST; выйти за его пределы нельзя.
func (s *Service) hostPath(rel string) (string, error) {
rel = strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/")
clean := path.Clean("/" + rel)
if clean != "/"+rel && rel != "" || strings.Contains(rel, "..") {
return "", apperr.Validation("local_path", CodeLocalPathInvalid, "path must be relative without ..", nil)
}
return filepath.Join(s.cfg.HostRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
}
// ListSources — хранилища с прогрессом сканов.
func (s *Service) ListSources(ctx context.Context) ([]Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
return s.store.ListSources(ctx)
}
// GetSource — хранилище по id.
func (s *Service) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src, err := s.store.GetSource(ctx, id)
return src, notFound(err)
}
// Progress — прогресс скана источника, если он был.
func (s *Service) Progress(id uuid.UUID) (Progress, bool) { return s.board.Get(id) }
// SourceInput — поля создания / изменения хранилища. nil — не задано.
type SourceInput struct {
Name *string
Type SourceType
NFSServer *string
NFSExport *string
NFSOptions *string
LocalPath *string
TagsFirst *bool
ScanIntervalMinutes *int // 0 — выключить
Enabled *bool
AcceptsUploads *bool
WriteTemplate *string // "" — убрать
}
func (s *Service) apply(src *Source, in SourceInput) error {
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || utf8.RuneCountInString(name) > 100 {
return apperr.Validation("name", CodeNameInvalid, "name must be 1-100 characters", map[string]any{"max": 100})
}
src.Name = name
}
if in.TagsFirst != nil {
src.TagsFirst = *in.TagsFirst
}
if in.ScanIntervalMinutes != nil {
switch m := *in.ScanIntervalMinutes; {
case m == 0:
src.ScanInterval = nil
case m < 5 || m > 60*24*30:
return apperr.Validation("scan_interval_minutes", CodeIntervalInvalid, "interval must be 5 minutes .. 30 days",
map[string]any{"min": 5})
default:
d := time.Duration(m) * time.Minute
src.ScanInterval = &d
}
}
if in.AcceptsUploads != nil {
src.AcceptsUploads = *in.AcceptsUploads
}
if in.WriteTemplate != nil {
if strings.TrimSpace(*in.WriteTemplate) == "" {
src.WriteTemplate = nil
} else {
tpl, err := meta.ParseTemplate(*in.WriteTemplate)
if err != nil {
return apperr.Validation("write_template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
text := tpl.String()
src.WriteTemplate = &text
}
}
switch src.Type {
case SourceLocal:
if in.LocalPath != nil {
rel := strings.Trim(strings.ReplaceAll(*in.LocalPath, "\\", "/"), "/")
if _, err := s.hostPath(rel); err != nil {
return err
}
src.LocalPath = &rel
}
if src.LocalPath == nil {
empty := ""
src.LocalPath = &empty
}
case SourceNFS:
if in.NFSServer != nil {
src.NFSServer = in.NFSServer
}
if in.NFSExport != nil {
src.NFSExport = in.NFSExport
}
if in.NFSOptions != nil {
src.NFSOptions = *in.NFSOptions
}
spec, err := nfsSpec(*src).Normalize(false)
if err != nil {
var ve *storageagent.ValidationError
if errors.As(err, &ve) {
return apperr.Validation(ve.Field, CodeNFSInvalid, ve.Reason, nil)
}
return err
}
src.NFSServer, src.NFSExport, src.NFSOptions = &spec.Server, &spec.Export, spec.Options
default:
return apperr.Validation("type", "validation.source_type_invalid", "unknown source type", nil)
}
return nil
}
func nfsSpec(src Source) storageagent.NFSSpec {
var spec storageagent.NFSSpec
if src.NFSServer != nil {
spec.Server = *src.NFSServer
}
if src.NFSExport != nil {
spec.Export = *src.NFSExport
}
spec.Options = src.NFSOptions
return spec
}
// CreateSource сохраняет хранилище и сразу пытается подключить его.
func (s *Service) CreateSource(ctx context.Context, in SourceInput) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src := Source{ID: newID(), Type: in.Type, TagsFirst: true, Status: "pending", NFSOptions: "ro,nfsvers=4.1,soft,timeo=150,retrans=3"}
if in.Name == nil {
in.Name = new(string)
}
if err := s.apply(&src, in); err != nil {
return Source{}, err
}
src, err := s.store.SaveSource(ctx, src)
if err != nil {
return Source{}, err
}
return s.connect(ctx, src)
}
// UpdateSource меняет хранилище; смена адреса или папки переподключает его.
func (s *Service) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return Source{}, notFound(err)
}
before := src
in.Type = src.Type
if err := s.apply(&src, in); err != nil {
return Source{}, err
}
if src, err = s.store.SaveSource(ctx, src); err != nil {
return Source{}, err
}
location := func(x Source) string {
return fmt.Sprint(x.LocalPath != nil && *x.LocalPath != "", deref(x.LocalPath), deref(x.NFSServer), deref(x.NFSExport), x.NFSOptions,
x.Type == SourceNFS && x.AcceptsUploads)
}
switch {
case in.Enabled != nil && !*in.Enabled:
if src.Type == SourceNFS {
_ = s.agent.Unmount(ctx, src.ID)
}
if err := s.store.SetSourceStatus(ctx, src.ID, "disabled", nil); err != nil {
return Source{}, err
}
return s.store.GetSource(ctx, src.ID)
case in.Enabled != nil && *in.Enabled, location(before) != location(src), src.Status == "error":
return s.connect(ctx, src)
}
return src, nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
// connect подключает хранилище и записывает статус: local — проверка каталога, nfs — монтирование агентом.
func (s *Service) connect(ctx context.Context, src Source) (Source, error) {
s.mu.Lock()
defer s.mu.Unlock()
status, message := s.check(ctx, src, true)
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
return Source{}, err
}
return s.store.GetSource(ctx, src.ID)
}
// check проверяет доступность; mount — смонтировать NFS (иначе только проверить точку).
func (s *Service) check(ctx context.Context, src Source, mount bool) (string, *string) {
msg := func(m string) *string { return &m }
switch src.Type {
case SourceLocal:
root, err := s.Root(src)
if err != nil {
return "error", msg(err.Error())
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return "error", msg("folder not found: " + deref(src.LocalPath))
}
return "mounted", nil
case SourceNFS:
if !mount {
root, _ := s.Root(src)
if _, err := os.Stat(root); err != nil {
return "error", msg(err.Error())
}
return "mounted", nil
}
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src), src.AcceptsUploads)
if err != nil {
return "error", msg(err.Error())
}
if res.Status != "mounted" {
return "error", msg(res.Message)
}
return "mounted", nil
}
return "error", msg("unknown type")
}
// DeleteSource отключает хранилище и удаляет его файлы из каталога.
func (s *Service) DeleteSource(ctx context.Context, id uuid.UUID) error {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return notFound(err)
}
if p, ok := s.board.Get(id); ok && p.Running {
return apperr.Conflict(CodeScanRunning, "scan is running")
}
if src.Type == SourceNFS {
if err := s.agent.Unmount(ctx, id); err != nil {
s.log.WarnContext(ctx, "unmount failed", "source_id", id, "err", err)
}
}
if err := s.store.DeleteSource(ctx, id); err != nil {
return notFound(err)
}
if err := s.store.CleanupOrphans(ctx); err != nil {
return err
}
if s.OnCatalogChanged != nil {
s.OnCatalogChanged()
}
return nil
}
// TestResult — итог проверки подключения.
type TestResult struct {
OK bool
Message string
AudioFiles int
Write *storageagent.WriteTestResult // только для accepts_uploads
}
// writeTarget — как агенту найти источник на запись.
func writeTarget(src Source) storageagent.WriteTestRequest {
req := storageagent.WriteTestRequest{SourceID: src.ID.String()}
if src.Type == SourceLocal {
lp := deref(src.LocalPath)
req.LocalPath = &lp
}
return req
}
// TestSource проверяет доступ к хранилищу и считает аудиофайлы (до 1000).
func (s *Service) TestSource(ctx context.Context, id uuid.UUID) (TestResult, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return TestResult{}, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return TestResult{}, notFound(err)
}
res, err := s.testRead(ctx, src)
if err != nil || !src.AcceptsUploads {
return res, err
}
w, err := s.agent.TestWrite(ctx, writeTarget(src))
if err != nil {
w = storageagent.WriteTestResult{Message: err.Error()}
}
res.Write = &w
return res, nil
}
func (s *Service) testRead(ctx context.Context, src Source) (TestResult, error) {
if src.Type == SourceNFS {
res, err := s.agent.Test(ctx, nfsSpec(src))
if err != nil {
return TestResult{Message: err.Error()}, nil
}
return TestResult{OK: res.Status == "mounted", Message: res.Message, AudioFiles: res.AudioFiles}, nil
}
root, err := s.Root(src)
if err != nil {
return TestResult{Message: err.Error()}, nil
}
n := 0
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && IsAudio(d.Name()) {
n++
if n >= 1000 {
return filepath.SkipAll
}
}
return nil
})
if err != nil {
return TestResult{Message: err.Error(), AudioFiles: n}, nil
}
return TestResult{OK: true, Message: fmt.Sprintf("found %d audio files", n), AudioFiles: n}, nil
}
// Patterns — шаблоны путей источника.
func (s *Service) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return nil, notFound(err)
}
return s.store.Patterns(ctx, id)
}
func compile(patterns []PatternRow) ([]meta.Pattern, error) {
var exprs []string
idx := []int{}
for i, p := range patterns {
if p.IsEnabled {
exprs = append(exprs, p.Regex)
idx = append(idx, i)
}
}
compiled, err := meta.CompilePatterns(exprs)
var pe *meta.PatternError
if errors.As(err, &pe) {
at := idx[pe.Index]
return nil, &apperr.Error{
Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason,
Params: map[string]any{"index": at, "reason": pe.Reason},
Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}},
}
}
return compiled, err
}
// PutPatterns проверяет и сохраняет шаблоны.
func (s *Service) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return nil, notFound(err)
}
if _, err := compile(patterns); err != nil {
return nil, err
}
if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil {
return nil, err
}
return s.store.Patterns(ctx, id)
}
// PreviewItem — разбор одного файла.
type PreviewItem struct {
RelPath string
PatternIndex *int
Skipped bool
PathFields map[string]string
Tags map[string]string
Result meta.Meta
Error string
}
// Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода.
func (s *Service) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, 0, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return nil, 0, notFound(err)
}
compiled, err := compile(patterns)
if err != nil {
return nil, 0, err
}
sample = min(max(sample, 1), 200)
root, err := s.Root(src)
if err != nil {
return nil, 0, err
}
paths, err := s.store.SampleFiles(ctx, id, sample)
if err != nil {
return nil, 0, err
}
if len(paths) == 0 {
found, err := walk(root, func(string) bool { return false })
if err != nil {
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
}
rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] })
for _, w := range found[:min(sample, len(found))] {
paths = append(paths, w.rel)
}
}
slices.Sort(paths)
items := make([]PreviewItem, len(paths))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, rel := range paths {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst)
}()
}
wg.Wait()
unmatched := 0
for _, it := range items {
if it.PatternIndex == nil {
unmatched++
}
}
return items, unmatched, nil
}
func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem {
it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}}
m := meta.MatchPath(patterns, rel)
if m.Index >= 0 {
idx := m.Index
it.PatternIndex = &idx
}
it.Skipped = m.Skip
for k, v := range m.Fields {
it.PathFields[k] = v
}
p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
it.Error = err.Error()
}
for k, v := range map[string]string{
"artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title,
"genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc,
} {
if v != "" {
it.Tags[k] = v
}
}
it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel)
if it.Result.Album == "" {
it.Result.Album = UnknownAlbum
}
return it
}
// 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
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return Progress{}, notFound(err)
}
if err := s.startScan(src, full); err != nil {
return Progress{}, err
}
p, _ := s.board.Get(id)
return p, nil
}
func (s *Service) startScan(src Source, full bool) error {
if src.Status != "mounted" {
return apperr.New(http.StatusConflict, CodeSourceOffline, "source is not connected")
}
root, err := s.Root(src)
if err != nil {
return err
}
if err := s.scanner.Start(src, root, full); errors.Is(err, ErrScanRunning) {
return apperr.Conflict(CodeScanRunning, "scan is already running")
} else if err != nil {
return err
}
return nil
}
// Status — состояние библиотеки (admin, moderator).
type Status struct {
Counts Counts
Sources []Source
Analysis AnalysisCounts
Wave WaveMetrics
}
// Status собирает счётчики и состояние источников.
func (s *Service) Status(ctx context.Context) (Status, error) {
if err := requirePerm(ctx, auth.PermViewLibraryStatus); err != nil {
return Status{}, err
}
c, err := s.store.Counts(ctx)
if err != nil {
return Status{}, err
}
sources, err := s.store.ListSources(ctx)
if err != nil {
return Status{}, err
}
analysis, err := s.store.AnalysisCounts(ctx)
if err != nil {
return Status{}, err
}
wave, err := s.store.WaveMetrics(ctx)
if err != nil {
return Status{}, err
}
return Status{Counts: c, Sources: sources, Analysis: analysis, Wave: wave}, nil
}
// FileErrors — файлы с ошибками чтения.
func (s *Service) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
return s.store.FileErrors(ctx, after, limit)
}
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
func (s *Service) HostDirs(ctx context.Context, rel string) (string, []string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", nil, err
}
dir, err := s.hostPath(rel)
if err != nil {
return "", nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", nil, apperr.Validation("path", CodeLocalPathInvalid, "folder not found", nil)
}
dirs := []string{}
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
dirs = append(dirs, e.Name())
}
}
slices.Sort(dirs)
return strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), dirs, nil
}
// Restore при старте api переподключает NFS-хранилища и проверяет local (после перезапуска агента).
func (s *Service) Restore(ctx context.Context) {
sources, err := s.store.ListSources(ctx)
if err != nil {
s.log.Error("restore sources", "err", err)
return
}
for _, src := range sources {
if src.Status == "disabled" {
continue
}
if _, err := s.connect(ctx, src); err != nil {
s.log.Error("restore source", "source_id", src.ID, "err", err)
}
}
}
// RunSchedule раз в минуту проверяет доступность хранилищ и запускает сканы по расписанию.
func (s *Service) RunSchedule(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
sources, err := s.store.ListSources(ctx)
if err != nil {
s.log.Warn("schedule: list sources", "err", err)
continue
}
for _, src := range sources {
if src.Status == "disabled" {
continue
}
s.healthCheck(ctx, src)
if src.ScanInterval == nil || src.Status != "mounted" {
continue
}
if src.LastScanAt == nil || time.Since(*src.LastScanAt) >= *src.ScanInterval {
if err := s.startScan(src, false); err != nil && !errors.Is(err, apperr.Conflict(CodeScanRunning, "")) {
s.log.Warn("scheduled scan", "source_id", src.ID, "err", err)
}
}
}
}
}
// healthCheck: недоступное хранилище — status=error (треки серые), вернулось — снова mounted.
func (s *Service) healthCheck(ctx context.Context, src Source) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
done := make(chan struct{})
var status string
var message *string
go func() {
status, message = s.check(ctx, src, false)
close(done)
}()
select {
case <-done:
case <-ctx.Done():
m := "storage does not respond"
status, message = "error", &m
}
if status == "error" && src.Type == SourceNFS && src.Status == "error" {
// Попробовать перемонтировать, как при старте.
if _, err := s.connect(ctx, src); err == nil {
return
}
}
if status != src.Status || deref(message) != deref(src.StatusMessage) {
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
s.log.Warn("health check", "source_id", src.ID, "err", err)
}
}
}
// templateSampleSize — сколько треков берёт проверка шаблона записи (docs/uploads.md#7).
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *Service) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
}
var exprs []string
for _, r := range rows {
if r.IsEnabled {
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
if err != nil {
return "", err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
}
return tpl, nil
}
// CheckWriteTemplate применяет шаблон к трекам источника и разбирает пути его выражениями обратно.
func (s *Service) CheckWriteTemplate(ctx context.Context, id uuid.UUID, template string) (meta.TemplateCheck, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return meta.TemplateCheck{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return meta.TemplateCheck{}, notFound(err)
}
tpl, err := meta.ParseTemplate(template)
if err != nil {
return meta.TemplateCheck{}, apperr.Validation("template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return meta.TemplateCheck{}, err
}
compiled, err := compile(rows)
if err != nil {
return meta.TemplateCheck{}, err
}
samples, err := s.store.TemplateSamples(ctx, id, templateSampleSize)
if err != nil {
return meta.TemplateCheck{}, err
}
return meta.CheckTemplate(tpl, compiled, samples), nil
}
+464
View File
@@ -0,0 +1,464 @@
package library
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/storageagent"
)
type SourceStore interface {
ListSources(context.Context) ([]Source, error)
GetSource(context.Context, uuid.UUID) (Source, error)
SaveSource(context.Context, Source) (Source, error)
SetSourceStatus(context.Context, uuid.UUID, string, *string) error
DeleteSource(context.Context, uuid.UUID) error
CleanupOrphans(context.Context) error
Counts(context.Context) (Counts, error)
AnalysisCounts(context.Context) (AnalysisCounts, error)
WaveMetrics(context.Context) (WaveMetrics, error)
FileErrors(context.Context, string, int) ([]FileError, error)
}
// SourceService owns source configuration, connectivity and diagnostics.
type SourceService struct {
store SourceStore
board *ProgressBoard
agent Agent
cfg Config
log *slog.Logger
mu sync.Mutex
OnCatalogChanged func()
}
// Root — каталог источника в файловой системе api.
func (s *SourceService) Root(src Source) (string, error) {
switch src.Type {
case SourceLocal:
rel := ""
if src.LocalPath != nil {
rel = *src.LocalPath
}
return s.hostPath(rel)
case SourceNFS:
return filepath.Join(s.cfg.LibraryRoot, src.ID.String()), nil
}
return "", fmt.Errorf("library: unknown source type %q", src.Type)
}
// hostPath — путь внутри LIBRARY_HOST; выйти за его пределы нельзя.
func (s *SourceService) hostPath(rel string) (string, error) {
rel = strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/")
clean := path.Clean("/" + rel)
if clean != "/"+rel && rel != "" || strings.Contains(rel, "..") {
return "", apperr.Validation("local_path", CodeLocalPathInvalid, "path must be relative without ..", nil)
}
return filepath.Join(s.cfg.HostRoot, filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
}
// ListSources — хранилища с прогрессом сканов.
func (s *SourceService) ListSources(ctx context.Context) ([]Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
return s.store.ListSources(ctx)
}
// GetSource — хранилище по id.
func (s *SourceService) GetSource(ctx context.Context, id uuid.UUID) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src, err := s.store.GetSource(ctx, id)
return src, notFound(err)
}
// Progress — прогресс скана источника, если он был.
func (s *SourceService) Progress(id uuid.UUID) (Progress, bool) { return s.board.Get(id) }
// SourceInput — поля создания / изменения хранилища. nil — не задано.
type SourceInput struct {
Name *string
Type SourceType
NFSServer *string
NFSExport *string
NFSOptions *string
LocalPath *string
TagsFirst *bool
ScanIntervalMinutes *int // 0 — выключить
Enabled *bool
AcceptsUploads *bool
WriteTemplate *string // "" — убрать
}
func (s *SourceService) apply(src *Source, in SourceInput) error {
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || utf8.RuneCountInString(name) > 100 {
return apperr.Validation("name", CodeNameInvalid, "name must be 1-100 characters", map[string]any{"max": 100})
}
src.Name = name
}
if in.TagsFirst != nil {
src.TagsFirst = *in.TagsFirst
}
if in.ScanIntervalMinutes != nil {
switch m := *in.ScanIntervalMinutes; {
case m == 0:
src.ScanInterval = nil
case m < 5 || m > 60*24*30:
return apperr.Validation("scan_interval_minutes", CodeIntervalInvalid, "interval must be 5 minutes .. 30 days",
map[string]any{"min": 5})
default:
d := time.Duration(m) * time.Minute
src.ScanInterval = &d
}
}
if in.AcceptsUploads != nil {
src.AcceptsUploads = *in.AcceptsUploads
}
if in.WriteTemplate != nil {
if strings.TrimSpace(*in.WriteTemplate) == "" {
src.WriteTemplate = nil
} else {
tpl, err := meta.ParseTemplate(*in.WriteTemplate)
if err != nil {
return apperr.Validation("write_template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
text := tpl.String()
src.WriteTemplate = &text
}
}
switch src.Type {
case SourceLocal:
if in.LocalPath != nil {
rel := strings.Trim(strings.ReplaceAll(*in.LocalPath, "\\", "/"), "/")
if _, err := s.hostPath(rel); err != nil {
return err
}
src.LocalPath = &rel
}
if src.LocalPath == nil {
empty := ""
src.LocalPath = &empty
}
case SourceNFS:
if in.NFSServer != nil {
src.NFSServer = in.NFSServer
}
if in.NFSExport != nil {
src.NFSExport = in.NFSExport
}
if in.NFSOptions != nil {
src.NFSOptions = *in.NFSOptions
}
spec, err := nfsSpec(*src).Normalize(false)
if err != nil {
var ve *storageagent.ValidationError
if errors.As(err, &ve) {
return apperr.Validation(ve.Field, CodeNFSInvalid, ve.Reason, nil)
}
return err
}
src.NFSServer, src.NFSExport, src.NFSOptions = &spec.Server, &spec.Export, spec.Options
default:
return apperr.Validation("type", "validation.source_type_invalid", "unknown source type", nil)
}
return nil
}
func nfsSpec(src Source) storageagent.NFSSpec {
var spec storageagent.NFSSpec
if src.NFSServer != nil {
spec.Server = *src.NFSServer
}
if src.NFSExport != nil {
spec.Export = *src.NFSExport
}
spec.Options = src.NFSOptions
return spec
}
// CreateSource сохраняет хранилище и сразу пытается подключить его.
func (s *SourceService) CreateSource(ctx context.Context, in SourceInput) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src := Source{ID: newID(), Type: in.Type, TagsFirst: true, Status: "pending", NFSOptions: "ro,nfsvers=4.1,soft,timeo=150,retrans=3"}
if in.Name == nil {
in.Name = new(string)
}
if err := s.apply(&src, in); err != nil {
return Source{}, err
}
src, err := s.store.SaveSource(ctx, src)
if err != nil {
return Source{}, err
}
return s.connect(ctx, src)
}
// UpdateSource меняет хранилище; смена адреса или папки переподключает его.
func (s *SourceService) UpdateSource(ctx context.Context, id uuid.UUID, in SourceInput) (Source, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Source{}, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return Source{}, notFound(err)
}
before := src
in.Type = src.Type
if err := s.apply(&src, in); err != nil {
return Source{}, err
}
if src, err = s.store.SaveSource(ctx, src); err != nil {
return Source{}, err
}
location := func(x Source) string {
return fmt.Sprint(x.LocalPath != nil && *x.LocalPath != "", deref(x.LocalPath), deref(x.NFSServer), deref(x.NFSExport), x.NFSOptions,
x.Type == SourceNFS && x.AcceptsUploads)
}
switch {
case in.Enabled != nil && !*in.Enabled:
if src.Type == SourceNFS {
_ = s.agent.Unmount(ctx, src.ID)
}
if err := s.store.SetSourceStatus(ctx, src.ID, "disabled", nil); err != nil {
return Source{}, err
}
return s.store.GetSource(ctx, src.ID)
case in.Enabled != nil && *in.Enabled, location(before) != location(src), src.Status == "error":
return s.connect(ctx, src)
}
return src, nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
// connect подключает хранилище и записывает статус: local — проверка каталога, nfs — монтирование агентом.
func (s *SourceService) connect(ctx context.Context, src Source) (Source, error) {
s.mu.Lock()
defer s.mu.Unlock()
status, message := s.check(ctx, src, true)
if err := s.store.SetSourceStatus(ctx, src.ID, status, message); err != nil {
return Source{}, err
}
return s.store.GetSource(ctx, src.ID)
}
// check проверяет доступность; mount — смонтировать NFS (иначе только проверить точку).
func (s *SourceService) check(ctx context.Context, src Source, mount bool) (string, *string) {
msg := func(m string) *string { return &m }
switch src.Type {
case SourceLocal:
root, err := s.Root(src)
if err != nil {
return "error", msg(err.Error())
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return "error", msg("folder not found: " + deref(src.LocalPath))
}
return "mounted", nil
case SourceNFS:
if !mount {
root, _ := s.Root(src)
if _, err := os.Stat(root); err != nil {
return "error", msg(err.Error())
}
return "mounted", nil
}
res, err := s.agent.Mount(ctx, src.ID, nfsSpec(src), src.AcceptsUploads)
if err != nil {
return "error", msg(err.Error())
}
if res.Status != "mounted" {
return "error", msg(res.Message)
}
return "mounted", nil
}
return "error", msg("unknown type")
}
// DeleteSource отключает хранилище и удаляет его файлы из каталога.
func (s *SourceService) DeleteSource(ctx context.Context, id uuid.UUID) error {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return notFound(err)
}
if p, ok := s.board.Get(id); ok && p.Running {
return apperr.Conflict(CodeScanRunning, "scan is running")
}
if src.Type == SourceNFS {
if err := s.agent.Unmount(ctx, id); err != nil {
s.log.WarnContext(ctx, "unmount failed", "source_id", id, "err", err)
}
}
if err := s.store.DeleteSource(ctx, id); err != nil {
return notFound(err)
}
if err := s.store.CleanupOrphans(ctx); err != nil {
return err
}
if s.OnCatalogChanged != nil {
s.OnCatalogChanged()
}
return nil
}
// TestResult — итог проверки подключения.
type TestResult struct {
OK bool
Message string
AudioFiles int
Write *storageagent.WriteTestResult // только для accepts_uploads
}
// writeTarget — как агенту найти источник на запись.
func writeTarget(src Source) storageagent.WriteTestRequest {
req := storageagent.WriteTestRequest{SourceID: src.ID.String()}
if src.Type == SourceLocal {
lp := deref(src.LocalPath)
req.LocalPath = &lp
}
return req
}
// TestSource проверяет доступ к хранилищу и считает аудиофайлы (до 1000).
func (s *SourceService) TestSource(ctx context.Context, id uuid.UUID) (TestResult, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return TestResult{}, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return TestResult{}, notFound(err)
}
res, err := s.testRead(ctx, src)
if err != nil || !src.AcceptsUploads {
return res, err
}
w, err := s.agent.TestWrite(ctx, writeTarget(src))
if err != nil {
w = storageagent.WriteTestResult{Message: err.Error()}
}
res.Write = &w
return res, nil
}
func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, error) {
if src.Type == SourceNFS {
res, err := s.agent.Test(ctx, nfsSpec(src))
if err != nil {
return TestResult{Message: err.Error()}, nil
}
return TestResult{OK: res.Status == "mounted", Message: res.Message, AudioFiles: res.AudioFiles}, nil
}
root, err := s.Root(src)
if err != nil {
return TestResult{Message: err.Error()}, nil
}
n := 0
err = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && IsAudio(d.Name()) {
n++
if n >= 1000 {
return filepath.SkipAll
}
}
return nil
})
if err != nil {
return TestResult{Message: err.Error(), AudioFiles: n}, nil
}
return TestResult{OK: true, Message: fmt.Sprintf("found %d audio files", n), AudioFiles: n}, nil
}
// Status — состояние библиотеки (admin, moderator).
type Status struct {
Counts Counts
Sources []Source
Analysis AnalysisCounts
Wave WaveMetrics
}
// Status собирает счётчики и состояние источников.
func (s *SourceService) Status(ctx context.Context) (Status, error) {
if err := requirePerm(ctx, auth.PermViewLibraryStatus); err != nil {
return Status{}, err
}
c, err := s.store.Counts(ctx)
if err != nil {
return Status{}, err
}
sources, err := s.store.ListSources(ctx)
if err != nil {
return Status{}, err
}
analysis, err := s.store.AnalysisCounts(ctx)
if err != nil {
return Status{}, err
}
wave, err := s.store.WaveMetrics(ctx)
if err != nil {
return Status{}, err
}
return Status{Counts: c, Sources: sources, Analysis: analysis, Wave: wave}, nil
}
// FileErrors — файлы с ошибками чтения.
func (s *SourceService) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
return s.store.FileErrors(ctx, after, limit)
}
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", nil, err
}
dir, err := s.hostPath(rel)
if err != nil {
return "", nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", nil, apperr.Validation("path", CodeLocalPathInvalid, "folder not found", nil)
}
dirs := []string{}
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
dirs = append(dirs, e.Name())
}
}
slices.Sort(dirs)
return strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), dirs, nil
}
+239
View File
@@ -0,0 +1,239 @@
package library
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"path/filepath"
"slices"
"sync"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
)
type PatternStore interface {
GetSource(context.Context, uuid.UUID) (Source, error)
Patterns(context.Context, uuid.UUID) ([]PatternRow, error)
ReplacePatterns(context.Context, uuid.UUID, []PatternRow) error
SampleFiles(context.Context, uuid.UUID, int) ([]string, error)
TemplateSamples(context.Context, uuid.UUID, int) ([]meta.TemplateSample, error)
}
// TemplateService owns metadata pattern preview and write template validation.
type TemplateService struct {
store PatternStore
prober Prober
root func(Source) (string, error)
}
// Patterns — шаблоны путей источника.
func (s *TemplateService) Patterns(ctx context.Context, id uuid.UUID) ([]PatternRow, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return nil, notFound(err)
}
return s.store.Patterns(ctx, id)
}
func compile(patterns []PatternRow) ([]meta.Pattern, error) {
var exprs []string
idx := []int{}
for i, p := range patterns {
if p.IsEnabled {
exprs = append(exprs, p.Regex)
idx = append(idx, i)
}
}
compiled, err := meta.CompilePatterns(exprs)
var pe *meta.PatternError
if errors.As(err, &pe) {
at := idx[pe.Index]
return nil, &apperr.Error{
Status: http.StatusUnprocessableEntity, Code: CodePatternInvalid, Detail: pe.Reason,
Params: map[string]any{"index": at, "reason": pe.Reason},
Fields: []apperr.FieldError{{Field: fmt.Sprintf("patterns[%d].regex", at), Code: CodePatternInvalid}},
}
}
return compiled, err
}
// PutPatterns проверяет и сохраняет шаблоны.
func (s *TemplateService) PutPatterns(ctx context.Context, id uuid.UUID, patterns []PatternRow) ([]PatternRow, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return nil, notFound(err)
}
if _, err := compile(patterns); err != nil {
return nil, err
}
if err := s.store.ReplacePatterns(ctx, id, patterns); err != nil {
return nil, err
}
return s.store.Patterns(ctx, id)
}
// PreviewItem — разбор одного файла.
type PreviewItem struct {
RelPath string
PatternIndex *int
Skipped bool
PathFields map[string]string
Tags map[string]string
Result meta.Meta
Error string
}
// Preview применяет шаблоны к выборке файлов без сохранения: из каталога, а до первого скана — из обхода.
func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []PatternRow, sample int) ([]PreviewItem, int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, 0, err
}
src, err := s.store.GetSource(ctx, id)
if err != nil {
return nil, 0, notFound(err)
}
compiled, err := compile(patterns)
if err != nil {
return nil, 0, err
}
sample = min(max(sample, 1), 200)
root, err := s.root(src)
if err != nil {
return nil, 0, err
}
paths, err := s.store.SampleFiles(ctx, id, sample)
if err != nil {
return nil, 0, err
}
if len(paths) == 0 {
found, err := walk(root, func(string) bool { return false })
if err != nil {
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
}
rand.Shuffle(len(found), func(i, j int) { found[i], found[j] = found[j], found[i] })
for _, w := range found[:min(sample, len(found))] {
paths = append(paths, w.rel)
}
}
slices.Sort(paths)
items := make([]PreviewItem, len(paths))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, rel := range paths {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
items[i] = s.previewOne(ctx, root, rel, compiled, src.TagsFirst)
}()
}
wg.Wait()
unmatched := 0
for _, it := range items {
if it.PatternIndex == nil {
unmatched++
}
}
return items, unmatched, nil
}
func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patterns []meta.Pattern, tagsFirst bool) PreviewItem {
it := PreviewItem{RelPath: rel, PathFields: map[string]string{}, Tags: map[string]string{}}
m := meta.MatchPath(patterns, rel)
if m.Index >= 0 {
idx := m.Index
it.PatternIndex = &idx
}
it.Skipped = m.Skip
for k, v := range m.Fields {
it.PathFields[k] = v
}
p, err := s.prober.Probe(ctx, filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
it.Error = err.Error()
}
for k, v := range map[string]string{
"artist": p.Tags.Artist, "albumartist": p.Tags.AlbumArtist, "album": p.Tags.Album, "title": p.Tags.Title,
"genre": p.Tags.Genre, "year": p.Tags.Year, "track": p.Tags.Track, "disc": p.Tags.Disc,
} {
if v != "" {
it.Tags[k] = v
}
}
it.Result = meta.Merge(p.Tags, meta.FromPath(m.Fields), tagsFirst, rel)
if it.Result.Album == "" {
it.Result.Album = UnknownAlbum
}
return it
}
// templateSampleSize — сколько треков берёт проверка шаблона записи (docs/uploads.md#7).
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
}
var exprs []string
for _, r := range rows {
if r.IsEnabled {
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
if err != nil {
return "", err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
}
return tpl, nil
}
// CheckWriteTemplate применяет шаблон к трекам источника и разбирает пути его выражениями обратно.
func (s *TemplateService) CheckWriteTemplate(ctx context.Context, id uuid.UUID, template string) (meta.TemplateCheck, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return meta.TemplateCheck{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return meta.TemplateCheck{}, notFound(err)
}
tpl, err := meta.ParseTemplate(template)
if err != nil {
return meta.TemplateCheck{}, apperr.Validation("template", CodeTemplateInvalid, err.Error(), map[string]any{"reason": err.Error()})
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return meta.TemplateCheck{}, err
}
compiled, err := compile(rows)
if err != nil {
return meta.TemplateCheck{}, err
}
samples, err := s.store.TemplateSamples(ctx, id, templateSampleSize)
if err != nil {
return meta.TemplateCheck{}, err
}
return meta.CheckTemplate(tpl, compiled, samples), nil
}
+88
View File
@@ -0,0 +1,88 @@
package playback
import (
"context"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
)
// EventService owns event acceptance and its transaction; HTTP only authenticates and maps DTOs.
type EventService struct {
store *Store
now func() time.Time
}
func (s EventService) Accept(ctx context.Context, p auth.Principal, events []gen.PlayEvent) (gen.PostEvents200JSONResponse, error) {
now := s.now()
ids := make([]uuid.UUID, 0, len(events))
for _, e := range events {
ids = append(ids, e.TrackId)
}
durations, err := s.store.TrackDurations(ctx, ids)
if err != nil {
return gen.PostEvents200JSONResponse{}, err
}
clientType := s.store.SessionClientType(ctx, p.SessionID)
out := gen.PostEvents200JSONResponse{}
tx, err := s.store.Begin(ctx)
if err != nil {
return gen.PostEvents200JSONResponse{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, e := range events {
dur, known := durations[e.TrackId]
at := e.OccurredAt.UTC()
if !known || at.Before(now.Add(-maxEventAge)) {
out.Rejected++
continue
}
if at.After(now.Add(maxEventClockSkew)) {
at = now // часы клиента спешат
}
ev := Event{
ID: e.Id, Type: string(e.Type), TrackID: e.TrackId, OccurredAt: at, PositionMs: e.PositionMs,
PlayedMs: e.PlayedMs, ContextID: e.ContextId, WaveSessionID: e.WaveSessionId,
Offline: e.Offline != nil && *e.Offline,
}
if e.ContextType != nil {
ct := string(*e.ContextType)
ev.ContextType = &ct
}
inserted, err := s.store.InsertEvent(ctx, tx, p.UserID, clientType, ev)
if err != nil {
return gen.PostEvents200JSONResponse{}, err
}
if !inserted {
out.Duplicates++
continue
}
out.Accepted++
played := 0
if e.PlayedMs != nil {
played = *e.PlayedMs
}
o := Judge(ev.Type, played, dur)
if o != (Outcome{}) {
if err := s.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil {
return gen.PostEvents200JSONResponse{}, err
}
}
if outcome := impressionOutcome(ev.Type, o); outcome != "" && ev.ContextType != nil && *ev.ContextType == "wave" &&
ev.ContextID != nil {
if sid, err := uuid.Parse(*ev.ContextID); err == nil {
if err := s.store.MarkImpression(ctx, tx, sid, ev.TrackID, outcome); err != nil {
return gen.PostEvents200JSONResponse{}, err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return gen.PostEvents200JSONResponse{}, err
}
return out, nil
}
+7 -104
View File
@@ -34,11 +34,11 @@ const (
)
// Handler реализует playback- и collection-части gen.StrictServerInterface,
// а также library.Personalizer (ссылки на стрим и реакции в треках).
// персонализация каталога вынесена в Personalizer.
type Handler struct {
store *Store
catalog *library.Store
dto func() library.DTO
dto library.DTO
signer *sign.Signer
transcoder *Transcoder
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
@@ -48,8 +48,8 @@ type Handler struct {
now func() time.Time
}
// NewHandler создаёт обработчик. dto — DTO каталога с персонализацией (замыкание: DTO ссылается на сам Handler).
func NewHandler(store *Store, catalog *library.Store, dto func() library.DTO, signer *sign.Signer, tr *Transcoder,
// NewHandler создаёт обработчик. dto — независимо собранный DTO каталога с персонализацией.
func NewHandler(store *Store, catalog *library.Store, dto library.DTO, signer *sign.Signer, tr *Transcoder,
rootFor func(context.Context, uuid.UUID) (string, error), xAccel bool, log *slog.Logger,
) *Handler {
return &Handler{
@@ -66,34 +66,6 @@ func principal(ctx context.Context) (auth.Principal, error) {
return p, nil
}
// Personalize реализует library.Personalizer.
func (h *Handler) Personalize(ctx context.Context, rows []library.TrackRow, out []gen.Track) error {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return nil
}
ids := make([]uuid.UUID, len(rows))
for i, r := range rows {
ids[i] = r.ID
}
reactions, err := h.store.Reactions(ctx, p.UserID, ids)
if err != nil {
return err
}
now := h.now()
for i := range out {
if out[i].Available {
u := StreamURL(h.signer, p.UserID, rows[i].ID, now)
out[i].StreamUrl = &u
}
if r, ok := reactions[rows[i].ID]; ok {
rr := gen.Reaction(r)
out[i].Reaction = &rr
}
}
return nil
}
// --- стрим ---
// StreamTrack реализует gen.StrictServerInterface.
@@ -236,7 +208,7 @@ func (h *Handler) GetTracksBatch(ctx context.Context, req gen.GetTracksBatchRequ
if err != nil {
return nil, err
}
items, err := h.dto().Tracks(ctx, rows)
items, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
@@ -251,74 +223,7 @@ func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObjec
if err != nil {
return nil, err
}
now := h.now()
ids := make([]uuid.UUID, 0, len(req.Body.Events))
for _, e := range req.Body.Events {
ids = append(ids, e.TrackId)
}
durations, err := h.store.TrackDurations(ctx, ids)
if err != nil {
return nil, err
}
clientType := h.store.SessionClientType(ctx, p.SessionID)
out := gen.PostEvents200JSONResponse{}
tx, err := h.store.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, e := range req.Body.Events {
dur, known := durations[e.TrackId]
at := e.OccurredAt.UTC()
if !known || at.Before(now.Add(-maxEventAge)) {
out.Rejected++
continue
}
if at.After(now.Add(maxEventClockSkew)) {
at = now // часы клиента спешат
}
ev := Event{
ID: e.Id, Type: string(e.Type), TrackID: e.TrackId, OccurredAt: at, PositionMs: e.PositionMs,
PlayedMs: e.PlayedMs, ContextID: e.ContextId, WaveSessionID: e.WaveSessionId,
Offline: e.Offline != nil && *e.Offline,
}
if e.ContextType != nil {
ct := string(*e.ContextType)
ev.ContextType = &ct
}
inserted, err := h.store.InsertEvent(ctx, tx, p.UserID, clientType, ev)
if err != nil {
return nil, err
}
if !inserted {
out.Duplicates++
continue
}
out.Accepted++
played := 0
if e.PlayedMs != nil {
played = *e.PlayedMs
}
o := Judge(ev.Type, played, dur)
if o != (Outcome{}) {
if err := h.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil {
return nil, err
}
}
if outcome := impressionOutcome(ev.Type, o); outcome != "" && ev.ContextType != nil && *ev.ContextType == "wave" &&
ev.ContextID != nil {
if sid, err := uuid.Parse(*ev.ContextID); err == nil {
if err := h.store.MarkImpression(ctx, tx, sid, ev.TrackID, outcome); err != nil {
return nil, err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return out, nil
return (EventService{store: h.store, now: h.now}).Accept(ctx, p, req.Body.Events)
}
// --- состояние плеера ---
@@ -441,7 +346,7 @@ func (h *Handler) hydrate(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]g
if err != nil {
return nil, err
}
dtos, err := h.dto().Tracks(ctx, rows)
dtos, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
@@ -591,8 +496,6 @@ func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReac
return gen.ClearTrackReaction204Response{}, nil
}
var _ library.Personalizer = (*Handler)(nil)
// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike.
func impressionOutcome(eventType string, o Outcome) string {
switch {
@@ -0,0 +1,58 @@
package playback
import (
"context"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/sign"
)
type ReactionReader interface {
Reactions(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error)
}
// Personalizer decorates catalog DTOs independently of HTTP handlers.
type Personalizer struct {
store ReactionReader
signer *sign.Signer
now func() time.Time
}
var _ library.Personalizer = (*Personalizer)(nil)
func NewPersonalizer(store ReactionReader, signer *sign.Signer) *Personalizer {
return &Personalizer{store: store, signer: signer, now: func() time.Time { return time.Now().UTC() }}
}
// Personalize реализует library.Personalizer.
func (h *Personalizer) Personalize(ctx context.Context, rows []library.TrackRow, out []gen.Track) error {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return nil
}
ids := make([]uuid.UUID, len(rows))
for i, r := range rows {
ids[i] = r.ID
}
reactions, err := h.store.Reactions(ctx, p.UserID, ids)
if err != nil {
return err
}
now := h.now()
for i := range out {
if out[i].Available {
u := StreamURL(h.signer, p.UserID, rows[i].ID, now)
out[i].StreamUrl = &u
}
if r, ok := reactions[rows[i].ID]; ok {
rr := gen.Reaction(r)
out[i].Reaction = &rr
}
}
return nil
}
@@ -0,0 +1,55 @@
package playback
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/sign"
)
type reactionReaderFunc func(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error)
func (f reactionReaderFunc) Reactions(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return f(ctx, user, ids)
}
func TestPersonalizerBatch(t *testing.T) {
user, first, second := uuid.New(), uuid.New(), uuid.New()
calls := 0
reader := reactionReaderFunc(func(_ context.Context, got uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]string, error) {
calls++
require.Equal(t, user, got)
require.Equal(t, []uuid.UUID{first, second}, ids)
return map[uuid.UUID]string{second: "dislike"}, nil
})
signer, err := sign.New("")
require.NoError(t, err)
p := NewPersonalizer(reader, signer)
now := time.Now().UTC()
p.now = func() time.Time { return now }
rows := []library.TrackRow{{ID: first}, {ID: second}}
out := []gen.Track{{Available: true}, {Available: false}}
require.NoError(t, p.Personalize(context.Background(), rows, out))
require.Zero(t, calls, "anonymous catalog does not query personal data")
ctx := auth.WithPrincipal(context.Background(), auth.Principal{UserID: user})
require.NoError(t, p.Personalize(ctx, rows, out))
require.Equal(t, 1, calls, "one reaction query for the entire batch")
require.Equal(t, StreamURL(signer, user, first, now), *out[0].StreamUrl)
require.Nil(t, out[1].StreamUrl, "unavailable tracks must not receive stream links")
require.Equal(t, gen.Reaction("dislike"), *out[1].Reaction)
}
func TestPersonalizerReadFailure(t *testing.T) {
failure := errors.New("database unavailable")
p := NewPersonalizer(reactionReaderFunc(func(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error) { return nil, failure }), nil)
ctx := auth.WithPrincipal(context.Background(), auth.Principal{UserID: uuid.New()})
require.ErrorIs(t, p.Personalize(ctx, nil, nil), failure)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
if err != nil {
return out, err
}
dtos, err := h.dto().Tracks(ctx, tracks)
dtos, err := h.dto.Tracks(ctx, tracks)
if err != nil {
return out, err
}
@@ -42,7 +42,7 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
st := NewStore(pool)
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
h := &Handler{store: st, catalog: library.NewStore(pool), dto: library.DTO{}}
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
got, err := h.playlist(ctx, owner, id)
require.NoError(t, err)
@@ -0,0 +1,99 @@
package uploads
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ChangeStatus deliberately does not expose pgx to use cases.
func (st *Store) ChangeStatus(ctx context.Context, id uuid.UUID, from []Status, to Status) error {
return st.Transition(ctx, id, from, to, nil)
}
// UpdateMetadata uses one guarded UPDATE; a concurrent decision is reported to the caller.
func (st *Store) UpdateMetadata(ctx context.Context, id uuid.UUID, m Meta, edited bool, pl placement) error {
raw, err := json.Marshal(m)
if err != nil {
return err
}
result, err := st.pool.Exec(ctx, `UPDATE upload_files SET metadata=$2, metadata_edited=$3, target_source_id=$4,
target_rel_path=$5, status_reason=$6, matched_artist_id=$7, matched_album_id=$8, updated_at=now()
WHERE id=$1 AND status IN ('pending_review','import_failed')`,
id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID)
if err != nil {
return fmt.Errorf("uploads: update: %w", err)
}
if result.RowsAffected() != 1 {
return ErrWrongStatus
}
return nil
}
// Decide keeps quota release, status and reviewer metadata in the same transaction.
func (st *Store) Decide(ctx context.Context, id, reviewer uuid.UUID, approve bool, reason string) error {
from, to := []Status{StatusPendingReview, StatusImportFailed}, StatusRejected
if approve {
from, to, reason = []Status{StatusPendingReview}, StatusApproved, ""
}
return st.Transition(ctx, id, from, to, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by=$2, reviewed_at=now(), status_reason=$3 WHERE id=$1`, id, reviewer, nullable(reason))
return err
})
}
func (st *Store) FailImport(ctx context.Context, id uuid.UUID, reason string) error {
return st.Transition(ctx, id, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason=$2 WHERE id=$1`, id, reason)
return err
})
}
func (st *Store) CompleteImport(ctx context.Context, id, sourceID uuid.UUID, rel string, mediaFileID uuid.UUID) error {
return st.Transition(ctx, id, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `UPDATE upload_files SET target_source_id=$2, target_rel_path=$3 WHERE id=$1`, id, sourceID, rel); err != nil {
return err
}
return MarkImported(ctx, tx, id, mediaFileID)
})
}
func (st *Store) ResetImporting(ctx context.Context) error {
_, err := st.pool.Exec(ctx, `UPDATE upload_files SET status='approved', updated_at=now() WHERE status='importing'`)
return err
}
func (st *Store) SaveOutcome(ctx context.Context, f File, o outcome) error {
return st.Transition(ctx, f.ID, []Status{StatusProcessing}, o.status, func(tx pgx.Tx) error {
var (
tags, metaJSON []byte
points []int32
)
if o.tags != nil {
tags, _ = json.Marshal(o.tags)
}
if o.meta != nil {
metaJSON, _ = json.Marshal(o.meta)
}
if o.print != nil {
points = make([]int32, len(o.print.Points))
for i, p := range o.print.Points {
points[i] = int32(p)
}
}
p := o.probe
_, err := tx.Exec(ctx, `
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
matched_artist_id = $18, matched_album_id = $19
WHERE id = $1`,
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
return err
})
}
+131
View File
@@ -0,0 +1,131 @@
package uploads
import (
"context"
"errors"
"github.com/google/uuid"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
)
// Ports are owned by the upload use cases; implementations remain in library.
type SourceCatalog interface {
GetSource(context.Context, uuid.UUID) (library.Source, error)
ListSources(context.Context) ([]library.Source, error)
Patterns(context.Context, uuid.UUID) ([]library.PatternRow, error)
}
type RootResolver interface {
Root(library.Source) (string, error)
}
type ImportedScanner interface {
ScanFile(context.Context, library.Source, string, string) (uuid.UUID, error)
}
type PlacementStore interface {
Settings(context.Context) (Settings, error)
MatchCatalog(context.Context, string, string) (*uuid.UUID, *uuid.UUID, int, error)
BatchMaxDisc(context.Context, uuid.UUID, string) (int, error)
}
type PlacementPlanner struct {
store PlacementStore
lib SourceCatalog
roots RootResolver
}
// --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
type placement struct {
SourceID *uuid.UUID
RelPath *string
Reason string
ArtistID *uuid.UUID
AlbumID *uuid.UUID
src library.Source
root string
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *PlacementPlanner) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen
if id == nil {
st, err := s.store.Settings(ctx)
if err == nil {
id = st.DefaultSourceID
}
}
if id != nil {
src, err := s.lib.GetSource(ctx, *id)
if err != nil || !src.AcceptsUploads {
return library.Source{}, CodeNoTarget
}
return src, ""
}
sources, err := s.lib.ListSources(ctx)
if err != nil {
return library.Source{}, CodeNoTarget
}
var found []library.Source
for _, src := range sources {
if src.AcceptsUploads {
found = append(found, src)
}
}
if len(found) != 1 {
return library.Source{}, CodeNoTarget
}
return found[0], ""
}
// plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома
// переиспользуются в их написании (docs/uploads.md#очистка-имён).
func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement {
var (
pl placement
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
)
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
}
src, reason := s.pickSource(ctx, chosen)
if reason != "" {
pl.Reason = reason
return pl
}
pl.SourceID, pl.src = &src.ID, src
if src.WriteTemplate == nil {
pl.Reason = CodeTemplate
return pl
}
tpl, err := meta.ParseTemplate(*src.WriteTemplate)
if err != nil {
pl.Reason = CodeTemplate
return pl
}
if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil {
discs = max(discs, n)
}
if m.Disc != nil {
discs = max(discs, *m.Disc)
}
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
if err != nil {
var mf *meta.MissingFieldError
if errors.As(err, &mf) {
pl.Reason = CodeMissingField
} else {
pl.Reason = CodeTemplate
}
return pl
}
root, err := s.roots.Root(src)
if err != nil {
pl.Reason = CodeNoTarget
return pl
}
rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root
return pl
}
+13 -123
View File
@@ -3,9 +3,7 @@ package uploads
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path"
@@ -14,7 +12,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/auth"
"pipu/api/internal/library"
@@ -30,10 +27,10 @@ type Config struct {
// Service — очередь на добавление.
type Service struct {
store *Store
lib *library.Store
libSvc *library.Service
scanner *library.Scanner
store *Store
lib SourceCatalog
*PlacementPlanner
scanner ImportedScanner
agent library.Agent
prober library.Prober
fpcalc library.FPCalc
@@ -46,10 +43,10 @@ type Service struct {
}
// NewService создаёт сервис очереди.
func NewService(store *Store, lib *library.Store, libSvc *library.Service, scanner *library.Scanner, agent library.Agent,
func NewService(store *Store, lib SourceCatalog, roots RootResolver, scanner ImportedScanner, agent library.Agent,
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
return &Service{
store: store, lib: lib, libSvc: libSvc, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
store: store, lib: lib, PlacementPlanner: &PlacementPlanner{store: store, lib: lib, roots: roots}, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
now: func() time.Time { return time.Now().UTC() },
}
@@ -233,7 +230,7 @@ func cleanClientPath(p *string) *string {
// Complete — tus принял файл целиком: в обработку.
func (s *Service) Complete(ctx context.Context, f File) error {
err := s.store.Transition(ctx, f.ID, []Status{StatusUploading}, StatusProcessing, nil)
err := s.store.ChangeStatus(ctx, f.ID, []Status{StatusUploading}, StatusProcessing)
if err == nil {
wake(s.wakeProcess)
}
@@ -279,7 +276,7 @@ func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
if err != nil || f.AuthorID != p.UserID {
return apperr.NotFound(CodeNotFound, "upload not found")
}
if err := s.store.Transition(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn, nil); err != nil {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
return notFound(err)
}
s.removeStaging(f)
@@ -324,14 +321,8 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Fil
target = in.TargetSourceID
}
pl := s.plan(ctx, f, m, target)
raw, _ := json.Marshal(m)
_, err = s.store.pool.Exec(ctx, `
UPDATE upload_files SET metadata = $2, metadata_edited = $3, target_source_id = $4, target_rel_path = $5,
status_reason = $6, matched_artist_id = $7, matched_album_id = $8, updated_at = now()
WHERE id = $1 AND status IN ('pending_review', 'import_failed')`,
id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID)
if err != nil {
return File{}, fmt.Errorf("uploads: update: %w", err)
if err := s.store.UpdateMetadata(ctx, id, m, edited, pl); err != nil {
return File{}, notFound(err)
}
return s.store.File(ctx, id)
}
@@ -385,13 +376,8 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
if err != nil {
return notFound(err)
}
mark := func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by = $2, reviewed_at = now(), status_reason = $3 WHERE id = $1`,
id, reviewer, nullable(reason))
return err
}
if !approve {
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview, StatusImportFailed}, StatusRejected, mark); err != nil {
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
return notFound(err)
}
s.removeStaging(f)
@@ -406,7 +392,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
}
reason = ""
if err := s.store.Transition(ctx, id, []Status{StatusPendingReview}, StatusApproved, mark); err != nil {
if err := s.store.Decide(ctx, id, reviewer, true, reason); err != nil {
return notFound(err)
}
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
@@ -418,7 +404,7 @@ func (s *Service) Retry(ctx context.Context, id uuid.UUID) (File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return File{}, err
}
if err := s.store.Transition(ctx, id, []Status{StatusImportFailed}, StatusApproved, nil); err != nil {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusImportFailed}, StatusApproved); err != nil {
return File{}, notFound(err)
}
wake(s.wakeImport)
@@ -495,102 +481,6 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
return st, nil
}
// --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
type placement struct {
SourceID *uuid.UUID
RelPath *string
Reason string
ArtistID *uuid.UUID
AlbumID *uuid.UUID
src library.Source
root string
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *Service) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen
if id == nil {
st, err := s.store.Settings(ctx)
if err == nil {
id = st.DefaultSourceID
}
}
if id != nil {
src, err := s.lib.GetSource(ctx, *id)
if err != nil || !src.AcceptsUploads {
return library.Source{}, CodeNoTarget
}
return src, ""
}
sources, err := s.lib.ListSources(ctx)
if err != nil {
return library.Source{}, CodeNoTarget
}
var found []library.Source
for _, src := range sources {
if src.AcceptsUploads {
found = append(found, src)
}
}
if len(found) != 1 {
return library.Source{}, CodeNoTarget
}
return found[0], ""
}
// plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома
// переиспользуются в их написании (docs/uploads.md#очистка-имён).
func (s *Service) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement {
var (
pl placement
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
)
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
}
src, reason := s.pickSource(ctx, chosen)
if reason != "" {
pl.Reason = reason
return pl
}
pl.SourceID, pl.src = &src.ID, src
if src.WriteTemplate == nil {
pl.Reason = CodeTemplate
return pl
}
tpl, err := meta.ParseTemplate(*src.WriteTemplate)
if err != nil {
pl.Reason = CodeTemplate
return pl
}
if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil {
discs = max(discs, n)
}
if m.Disc != nil {
discs = max(discs, *m.Disc)
}
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
if err != nil {
var mf *meta.MissingFieldError
if errors.As(err, &mf) {
pl.Reason = CodeMissingField
} else {
pl.Reason = CodeTemplate
}
return pl
}
root, err := s.libSvc.Root(src)
if err != nil {
pl.Reason = CodeNoTarget
return pl
}
rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root
return pl
}
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
func extFor(f File) string {
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
+6 -49
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
@@ -17,7 +16,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
@@ -114,7 +112,7 @@ type outcome struct {
func (s *Service) process(ctx context.Context, f File) error {
abs := s.stagingAbs(f.StagingPath)
o := s.analyze(ctx, f, abs)
if err := s.saveOutcome(ctx, f, o); err != nil {
if err := s.store.SaveOutcome(ctx, f, o); err != nil {
return err
}
if o.status != StatusPendingReview {
@@ -256,38 +254,6 @@ func (s *Service) sourcePatterns(ctx context.Context, id uuid.UUID) []meta.Patte
return patterns
}
func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error {
return s.store.Transition(ctx, f.ID, []Status{StatusProcessing}, o.status, func(tx pgx.Tx) error {
var (
tags, metaJSON []byte
points []int32
)
if o.tags != nil {
tags, _ = json.Marshal(o.tags)
}
if o.meta != nil {
metaJSON, _ = json.Marshal(o.meta)
}
if o.print != nil {
points = make([]int32, len(o.print.Points))
for i, p := range o.print.Points {
points[i] = int32(p)
}
}
p := o.probe
_, err := tx.Exec(ctx, `
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
matched_artist_id = $18, matched_album_id = $19
WHERE id = $1`,
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
return err
})
}
// --- Импорт ---
// importNext импортирует один одобренный файл; false — работы нет.
@@ -300,17 +266,14 @@ func (s *Service) importNext(ctx context.Context) bool {
s.log.Error("uploads: next to import", "err", err)
return false
}
if err := s.store.Transition(ctx, f.ID, []Status{StatusApproved}, StatusImporting, nil); err != nil {
if err := s.store.ChangeStatus(ctx, f.ID, []Status{StatusApproved}, StatusImporting); err != nil {
// Файл успели отклонить — берём следующий; сбой БД — ждём тика.
return errors.Is(err, ErrWrongStatus)
}
if err := s.importFile(ctx, f); err != nil {
s.log.Warn("upload import failed", "upload_id", f.ID, "err", err)
msg := err.Error()
if ferr := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason = $2 WHERE id = $1`, f.ID, CodeImportFailed+": "+msg)
return err
}); ferr != nil {
if ferr := s.store.FailImport(ctx, f.ID, CodeImportFailed+": "+msg); ferr != nil {
s.log.Error("uploads: mark import failed", "upload_id", f.ID, "err", ferr)
}
}
@@ -360,13 +323,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
if err != nil {
return fmt.Errorf("scan: %w", err)
}
if err := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `UPDATE upload_files SET target_source_id = $2, target_rel_path = $3 WHERE id = $1`,
f.ID, pl.src.ID, rel); err != nil {
return err
}
return MarkImported(ctx, tx, f.ID, fileID)
}); err != nil {
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
return err
}
s.removeStaging(f)
@@ -439,7 +396,7 @@ func (s *Service) expire(ctx context.Context) {
if err != nil {
continue
}
if err := s.store.Transition(ctx, id, []Status{StatusUploading}, StatusExpired, nil); err == nil {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading}, StatusExpired); err == nil {
s.removeStaging(f)
}
}
@@ -447,7 +404,7 @@ func (s *Service) expire(ctx context.Context) {
// reconcile при старте: прерванный импорт — снова в approved; осиротевшие файлы staging удаляются.
func (s *Service) reconcile(ctx context.Context) {
if _, err := s.store.pool.Exec(ctx, `UPDATE upload_files SET status = 'approved', updated_at = now() WHERE status = 'importing'`); err != nil {
if err := s.store.ResetImporting(ctx); err != nil {
s.log.Error("uploads: reset importing", "err", err)
}
inUse, err := s.store.StagingInUse(ctx)
+33 -11
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
@@ -16,8 +17,6 @@ import (
const (
onboardingArtists = 40
onboardingGenres = 24
// История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям.
onboardingMinHistory = 10
)
// GetOnboarding реализует gen.StrictServerInterface.
@@ -27,13 +26,18 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
return nil, err
}
out := gen.GetOnboarding200JSONResponse{}
var done bool
var plays int
var raw []byte
if err := h.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM user_profiles_rec WHERE user_id = $1 AND onboarding IS NOT NULL),
(SELECT count(*) FROM user_track_stats WHERE user_id = $1)`, p.UserID).Scan(&done, &plays); err != nil {
SELECT (SELECT onboarding || jsonb_build_object('age_group', rec_age_group(onboarding))
FROM user_profiles_rec WHERE user_id = $1)`, p.UserID).Scan(&raw); err != nil {
return nil, fmt.Errorf("wave: onboarding state: %w", err)
}
if len(raw) > 0 {
out.Choice = &gen.OnboardingChoice{}
if err := json.Unmarshal(raw, out.Choice); err != nil {
return nil, err
}
}
rows, err := h.pool.Query(ctx, `
SELECT a.id, a.name, count(*) AS n,
@@ -61,7 +65,7 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
artists = append(artists, a)
}
rows.Close()
dto := h.dto()
dto := h.dto
for _, a := range artists {
item := struct {
CoverColors []string `json:"cover_colors"`
@@ -112,7 +116,7 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
TrackCount int `json:"track_count"`
}{}
}
out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0
out.Required = out.Choice == nil
return out, nil
}
@@ -122,6 +126,19 @@ func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequ
if err != nil {
return nil, err
}
if req.Body == nil {
return nil, apperr.Validation("body", "common.bad_request", "body required", nil)
}
if req.Body.Gender != nil && *req.Body.Gender != "male" && *req.Body.Gender != "female" && *req.Body.Gender != "other" {
return nil, apperr.Validation("gender", "common.bad_request", "invalid gender", nil)
}
if req.Body.AgeGroup != nil {
switch *req.Body.AgeGroup {
case "under_13", "13_19", "20_29", "30_39", "40_49", "50_59", "60_plus":
default:
return nil, apperr.Validation("age_group", "common.bad_request", "invalid age group", nil)
}
}
ids := req.Body.ArtistIds
if ids == nil {
ids = []uuid.UUID{}
@@ -130,10 +147,15 @@ func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequ
if genres == nil {
genres = []string{}
}
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres})
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres, "gender": req.Body.Gender, "age_group": req.Body.AgeGroup})
if _, err := h.pool.Exec(ctx, `
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding, updated_at = now()`,
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2::jsonb || jsonb_build_object('age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int), now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = CASE
WHEN rec_age_group(user_profiles_rec.onboarding) = EXCLUDED.onboarding->>'age_group'
THEN EXCLUDED.onboarding || jsonb_build_object(
'age_group', user_profiles_rec.onboarding->'age_group',
'age_group_year', user_profiles_rec.onboarding->'age_group_year')
ELSE EXCLUDED.onboarding END, updated_at = now()`,
p.UserID, raw); err != nil {
return nil, fmt.Errorf("wave: save onboarding: %w", err)
}
+23 -7
View File
@@ -139,14 +139,14 @@ type Handler struct {
pool *pgxpool.Pool
rec *Recommender
catalog *library.Store
dto func() library.DTO
dto library.DTO
log *slog.Logger
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
Warm func(ctx context.Context, trackIDs []uuid.UUID)
}
// NewHandler создаёт обработчик.
func NewHandler(pool *pgxpool.Pool, rec *Recommender, catalog *library.Store, dto func() library.DTO, log *slog.Logger) *Handler {
func NewHandler(pool *pgxpool.Pool, rec *Recommender, catalog *library.Store, dto library.DTO, log *slog.Logger) *Handler {
return &Handler{pool: pool, rec: rec, catalog: catalog, dto: dto, log: log}
}
@@ -323,7 +323,7 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
if err != nil {
return gen.WaveBatch{}, err
}
tracks, err := h.dto().Tracks(ctx, rows)
tracks, err := h.dto.Tracks(ctx, rows)
if err != nil {
return gen.WaveBatch{}, err
}
@@ -373,7 +373,18 @@ func (h *Handler) record(ctx context.Context, s session, picks []Pick, modelVer
// без дизлайков, недавно игравших и уже выданных; один трек исполнителя на пачку.
func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]Pick, error) {
rows, err := h.pool.Query(ctx, `
WITH liked AS (
WITH own AS (
SELECT onboarding FROM user_profiles_rec WHERE user_id = $1
), cohort AS (
SELECT r.track_id FROM track_reactions r
JOIN user_profiles_rec p ON p.user_id = r.user_id
JOIN users u ON u.id = p.user_id, own
WHERE r.reaction = 'like' AND p.user_id <> $1 AND NOT u.is_blocked
AND (own.onboarding->>'gender' IS NOT NULL OR rec_age_group(own.onboarding) IS NOT NULL)
AND (own.onboarding->>'gender' IS NULL OR p.onboarding->>'gender' = own.onboarding->>'gender')
AND (rec_age_group(own.onboarding) IS NULL OR rec_age_group(p.onboarding) = rec_age_group(own.onboarding))
GROUP BY r.track_id HAVING count(DISTINCT r.user_id) >= 2
), liked AS (
SELECT track_id FROM track_reactions WHERE user_id = $1 AND reaction = 'like'
), fav_artists AS (
SELECT DISTINCT ta.artist_id FROM track_artists ta JOIN liked l ON l.track_id = ta.track_id
@@ -384,7 +395,12 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
CASE WHEN t.id IN (SELECT track_id FROM liked) THEN 'liked'
WHEN EXISTS (SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id
AND ta.artist_id IN (SELECT artist_id FROM fav_artists)) THEN 'artist'
ELSE 'random' END AS source
WHEN EXISTS (SELECT 1 FROM own, track_artists ta
WHERE ta.track_id = t.id AND own.onboarding->'artist_ids' ? ta.artist_id::text) THEN 'onboarding'
WHEN EXISTS (SELECT 1 FROM own, track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id AND own.onboarding->'genres' ? g.name) THEN 'onboarding'
WHEN t.id IN (SELECT track_id FROM cohort) THEN 'cohort'
ELSE 'random' END AS source
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN media_files f ON f.id = t.primary_file_id AND f.missing_since IS NULL
@@ -395,7 +411,7 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
AND e.type = 'play_start' AND e.occurred_at > now() - interval '3 hours')
)
SELECT DISTINCT ON (artist_id) id::text, source FROM (
SELECT * FROM pool ORDER BY CASE source WHEN 'liked' THEN 0 WHEN 'artist' THEN 1 ELSE 2 END, random() LIMIT 200
SELECT * FROM pool ORDER BY CASE source WHEN 'liked' THEN 0 WHEN 'artist' THEN 1 WHEN 'onboarding' THEN 2 WHEN 'cohort' THEN 3 ELSE 4 END, random() LIMIT 200
) c
ORDER BY artist_id, random()
LIMIT 50`, s.UserID, served)
@@ -410,7 +426,7 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
return nil, fmt.Errorf("wave: fallback: %w", err)
}
// DISTINCT ON упорядочил по исполнителю — перемешиваем, предпочитая лайки и любимых исполнителей.
rank := map[string]int{"liked": 0, "artist": 1, "random": 2}
rank := map[string]int{"liked": 0, "artist": 1, "onboarding": 2, "cohort": 3, "random": 4}
shuffled := shuffle(picks)
stableSort(shuffled, func(a, b Pick) bool { return rank[a.Source] < rank[b.Source] })
// Половина пачки — любимое, половина — случайное: запасная волна не должна быть одними лайками.
@@ -0,0 +1,22 @@
-- +goose Up
-- Keep the original group and year; approximate current age from its midpoint.
-- +goose StatementBegin
CREATE FUNCTION rec_age_group(choice jsonb) RETURNS text LANGUAGE sql STABLE AS $$
WITH age AS (
SELECT CASE choice->>'age_group'
WHEN 'under_13' THEN 6 WHEN '13_19' THEN 16 WHEN '20_29' THEN 24
WHEN '30_39' THEN 34 WHEN '40_49' THEN 44 WHEN '50_59' THEN 54
WHEN '60_plus' THEN 65 END
+ GREATEST(0, EXTRACT(YEAR FROM CURRENT_DATE)::int
- COALESCE((choice->>'age_group_year')::int, EXTRACT(YEAR FROM CURRENT_DATE)::int)) AS n
)
SELECT CASE WHEN n IS NULL THEN NULL WHEN n < 13 THEN 'under_13' WHEN n < 20 THEN '13_19'
WHEN n < 30 THEN '20_29' WHEN n < 40 THEN '30_39' WHEN n < 50 THEN '40_49'
WHEN n < 60 THEN '50_59' ELSE '60_plus' END FROM age
$$;
-- +goose StatementEnd
CREATE INDEX user_profiles_rec_gender_idx ON user_profiles_rec ((onboarding->>'gender'));
-- +goose Down
DROP INDEX user_profiles_rec_gender_idx;
DROP FUNCTION rec_age_group(jsonb);
@@ -161,6 +161,9 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
).fetchone()
if row and row[3] and len(scores) < ONBOARDING_UNTIL:
apply_onboarding(conn, scores, row[3])
apply_demographics(conn, scores, row[3], user_id)
for tid in disliked:
scores.pop(tid, None)
exploration = float(row[0]) if row else 0.15
taste_cf = parse_vector(row[1]) if row else None
bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {}
@@ -212,6 +215,35 @@ def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], ch
scores.setdefault(tid, v)
def apply_demographics(
conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any], user_id: str
) -> None:
"""A weak cold-start prior from actual likes of peers, without assumed musical stereotypes."""
gender, age = choice.get("gender"), choice.get("age_group")
if not gender and not age:
return
rows = conn.execute(
"""
WITH own AS (
SELECT onboarding FROM user_profiles_rec WHERE user_id = %s
), peers AS (
SELECT p.user_id FROM user_profiles_rec p JOIN users u ON u.id = p.user_id, own
WHERE p.user_id <> %s AND NOT u.is_blocked
AND (%s::text IS NULL OR p.onboarding->>'gender' = %s)
AND (%s::text IS NULL OR rec_age_group(p.onboarding) = rec_age_group(own.onboarding))
)
SELECT r.track_id::text, count(DISTINCT r.user_id) AS fans
FROM track_reactions r JOIN peers p ON p.user_id = r.user_id
WHERE r.reaction = 'like'
GROUP BY r.track_id HAVING count(DISTINCT r.user_id) >= 2
ORDER BY fans DESC, r.track_id LIMIT %s
""",
(user_id, user_id, gender, gender, age, ONBOARDING_LIMIT),
)
for tid, fans in rows:
scores.setdefault(tid, min(0.25, 0.05 * fans))
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
queries = {
@@ -181,3 +181,39 @@ def test_impression_features_roundtrip(world: dict[str, Any]) -> None:
assert any(r[0] == session and r[2] == "complete" for r in rows)
result = train_and_save(conn, time.time())
assert "skipped" in result["ranker"], "too few impressions — v1 stays"
def test_demographic_prior_advances_age_and_preserves_own_feedback(world: dict[str, Any]) -> None:
user = uid()
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')",
(user, f"cohort-{user[:6]}"),
)
# Target selected 13–19 twenty years ago; matching listeners are now 30–39.
conn.execute(
"INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, "
"jsonb_build_object('age_group', '13_19', 'gender', 'male', "
"'age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int - 20))",
(user,),
)
for peer in world["users"][:3]:
conn.execute(
"INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s) "
"ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding",
(peer, json.dumps({"gender": "male", "age_group": "30_39"})),
)
profile = load_profile(conn, user)
track = world["tracks"][0] # two matching listeners liked it
assert 0 < profile.scores[track] <= 0.25
assert not profile.played
conn.execute(
"INSERT INTO track_reactions (user_id, track_id, reaction) VALUES (%s, %s, 'dislike')",
(user, track),
)
assert track not in load_profile(conn, user).scores
conn.execute(
"UPDATE user_profiles_rec SET onboarding = %s WHERE user_id = %s",
(json.dumps({"gender": "female"}), user),
)
assert not load_profile(conn, user).scores, "no invented preferences when the cohort has no evidence"
@@ -0,0 +1,19 @@
from unittest.mock import MagicMock
from pipu_rec.wave.store import apply_demographics
def test_missing_demographics_do_not_create_a_prior() -> None:
conn = MagicMock()
scores = {"own": 3.0}
apply_demographics(conn, scores, {}, "user")
conn.execute.assert_not_called()
assert scores == {"own": 3.0}
def test_cohort_prior_is_weak_and_never_overwrites_personal_scores() -> None:
conn = MagicMock()
conn.execute.return_value = [("liked", 10), ("skipped", 10), ("peer", 100), ("small", 2)]
scores = {"liked": 3.0, "skipped": -1.0}
apply_demographics(conn, scores, {"gender": "male", "age_group": "13_19"}, "user")
assert scores == {"liked": 3.0, "skipped": -1.0, "peer": 0.25, "small": 0.1}