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
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
This commit is contained in:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -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
|
||||||
@@ -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`.
|
||||||
|
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||||
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
|
|||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
- Документация: [docs/README.md](docs/README.md)
|
- Документация: [docs/README.md](docs/README.md)
|
||||||
|
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
|
||||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||||
|
pnpm check:boundaries
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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-тесты не заменяют эту проверку.
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import NetInfo from '@react-native-community/netinfo';
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import {
|
import {
|
||||||
EventBuffer,
|
EventBuffer,
|
||||||
|
WaveCoordinator,
|
||||||
|
EARLY_SKIP_MS,
|
||||||
|
type WaveInfo,
|
||||||
|
type WaveOutcome,
|
||||||
|
type WaveFeedback,
|
||||||
PlayAttempt,
|
PlayAttempt,
|
||||||
normalizeGain,
|
normalizeGain,
|
||||||
unwrap,
|
unwrap,
|
||||||
@@ -24,12 +29,7 @@ import { eventId } from './eventId';
|
|||||||
|
|
||||||
export { offlineWave };
|
export { offlineWave };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export type { WaveInfo } from '@pipu/core';
|
||||||
sessionId: string;
|
|
||||||
activity: string | null;
|
|
||||||
mood: string | null;
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -44,10 +44,6 @@ export interface PlayerState {
|
|||||||
wave: WaveInfo | null;
|
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 SAVE_EVERY_MS = 10_000;
|
||||||
|
|
||||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
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 attempt: PlayAttempt | null = null;
|
||||||
private lastPositionMs = 0;
|
private lastPositionMs = 0;
|
||||||
private lastSave = 0;
|
private lastSave = 0;
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private readonly wavePolicy = new WaveCoordinator({
|
||||||
private waveLoading = false;
|
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 neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
private setupDone: Promise<void> | null = null;
|
private setupDone: Promise<void> | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
@@ -308,18 +309,22 @@ class Controller {
|
|||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
await this.setup();
|
await this.setup();
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
|
const version = this.wavePolicy.version;
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
// Нет связи с сервером — офлайн-волна из скачанного.
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||||
const local = offlineWave(downloads.downloaded());
|
const local = offlineWave(downloads.downloaded());
|
||||||
if (!local.length) throw e;
|
if (!local.length) throw e;
|
||||||
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||||
}
|
}
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
const tracks = res.tracks.filter(playable);
|
const tracks = res.tracks.filter(playable);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
this.finishAttempt(this.lastPositionMs / 1000);
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
this.wavePolicy.activate(res.session_id);
|
||||||
this.update({
|
this.update({
|
||||||
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
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 },
|
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) {
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||||
|
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
params: { path: { id: wave.sessionId } },
|
params: { path: { id: wave.sessionId } },
|
||||||
body: { activity, mood },
|
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 } });
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
if (res.tracks.some(playable)) {
|
if (res.tracks.some(playable)) {
|
||||||
@@ -351,8 +359,8 @@ class Controller {
|
|||||||
|
|
||||||
private leaveWave() {
|
private leaveWave() {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
if (!wave) return;
|
if (!wave) return;
|
||||||
this.waveFeedback = [];
|
|
||||||
this.update({ wave: null });
|
this.update({ wave: null });
|
||||||
void this.session?.client
|
void this.session?.client
|
||||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
@@ -360,46 +368,36 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
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);
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||||
await TrackPlayer.removeUpcomingTracks();
|
|
||||||
const add = tracks.filter(playable);
|
const add = tracks.filter(playable);
|
||||||
await TrackPlayer.add(await this.toRntp(add));
|
const native = await this.toRntp(add);
|
||||||
this.update({ queue: [...keep, ...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) {
|
private async refillWave(refresh = false) {
|
||||||
const wave = this.state.wave;
|
if (!this.session) return;
|
||||||
if (!wave || !this.session || this.waveLoading) return;
|
|
||||||
const remaining = this.state.queue.length - this.state.index - 1;
|
const remaining = this.state.queue.length - this.state.index - 1;
|
||||||
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||||
this.waveLoading = true;
|
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||||
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);
|
|
||||||
else {
|
else {
|
||||||
const add = res.tracks.filter(playable);
|
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({ queue: [...this.state.queue, ...add] });
|
||||||
}
|
}
|
||||||
this.update({ wave: { ...wave, fallback: res.fallback } });
|
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||||
} catch {
|
});
|
||||||
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
|
||||||
} finally {
|
|
||||||
this.waveLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- состояние ---
|
// --- состояние ---
|
||||||
@@ -443,6 +441,7 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
this.attempt = null;
|
this.attempt = null;
|
||||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||||
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 должен освобождать обработчики.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
import { UploadSettingsSection } from './UploadQueuePage';
|
import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
|
||||||
|
|
||||||
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
|
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
|
||||||
export function AdminSettingsPage() {
|
export function AdminSettingsPage() {
|
||||||
|
|||||||
@@ -1,640 +1,3 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import { UploadQueue } from '../../features/uploads/UploadQueue';
|
||||||
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 { useSession } from '../../app/session';
|
export function UploadQueuePage() { return <UploadQueue />; }
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,84 +1 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
export * from '../../features/uploads/common';
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,48 +1,57 @@
|
|||||||
import type { AuthSession } from '@pipu/core';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { Player } from './player';
|
import { AudioAnalysis } from './analysis';
|
||||||
|
import { AudioEngine } from './engine';
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
function fixture() {
|
function fixture() {
|
||||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
|
||||||
const sample = vi.fn((data: Uint8Array) => {
|
const sample = vi.fn((data: Uint8Array) => {
|
||||||
data.fill(0);
|
data.fill(0);
|
||||||
// 48 kHz / 1024: bins 1..4 cover the bass band.
|
// 48 kHz / 1024: bins 1..4 cover the bass band.
|
||||||
data.fill(255, 1, 5);
|
data.fill(255, 1, 5);
|
||||||
});
|
});
|
||||||
Object.assign(player, {
|
const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
|
||||||
state: { ...player.getState(), playing: true },
|
const analysis = new AudioAnalysis(() => analyser);
|
||||||
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
|
return { analysis, sample };
|
||||||
});
|
|
||||||
return { player, sample };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('audio reactivity', () => {
|
describe('audio reactivity', () => {
|
||||||
it('separates bass from the other frequency bands and shares the frame', () => {
|
it('separates bass from the other frequency bands and shares the frame', () => {
|
||||||
vi.spyOn(performance, 'now').mockReturnValue(100);
|
vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||||
const { player, sample } = fixture();
|
const { analysis, sample } = fixture();
|
||||||
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
|
expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
|
||||||
player.readAudio();
|
analysis.read(true);
|
||||||
expect(sample).toHaveBeenCalledTimes(1);
|
expect(sample).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns silence after pausing or muting', () => {
|
it('returns silence after pausing or muting', () => {
|
||||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||||
const { player, sample } = fixture();
|
const { analysis, sample } = fixture();
|
||||||
player.readAudio();
|
analysis.read(true);
|
||||||
Object.assign(player, { state: { ...player.getState(), playing: false } });
|
|
||||||
clock.mockReturnValue(120);
|
clock.mockReturnValue(120);
|
||||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||||
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
|
|
||||||
clock.mockReturnValue(140);
|
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);
|
expect(sample).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works when Web Audio is unavailable', () => {
|
it('works when Web Audio is unavailable', () => {
|
||||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
const analysis = new AudioAnalysis(() => null);
|
||||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,5 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
EventBuffer,
|
EventBuffer,
|
||||||
|
WaveCoordinator,
|
||||||
|
EARLY_SKIP_MS,
|
||||||
|
type WaveOutcome,
|
||||||
|
type WaveFeedback,
|
||||||
normalizeGain,
|
normalizeGain,
|
||||||
PlayAttempt,
|
PlayAttempt,
|
||||||
PlayQueue,
|
PlayQueue,
|
||||||
@@ -10,80 +14,29 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
} from '@pipu/core';
|
} 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 type { PlayContext, Track };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export type { WaveInfo } from '@pipu/core';
|
||||||
sessionId: string;
|
|
||||||
activity: string | null;
|
|
||||||
mood: string | null;
|
|
||||||
/** Рекомендатель недоступен — играет запасная волна. */
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SAVE_EVERY_MS = 10_000;
|
||||||
const FLUSH_EVERY_MS = 30_000;
|
const FLUSH_EVERY_MS = 30_000;
|
||||||
const PRELOAD_BEFORE_MS = 20_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,
|
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
|
||||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||||
*/
|
*/
|
||||||
export class Player {
|
export class Player {
|
||||||
private readonly audio: HTMLAudioElement;
|
private readonly engine: AudioEngine;
|
||||||
private readonly overlap: HTMLAudioElement;
|
private get audio() { return this.engine.audio; }
|
||||||
|
private get overlap() { return this.engine.overlap; }
|
||||||
private fading = false;
|
private fading = false;
|
||||||
private ignoreEnded = false;
|
private ignoreEnded = false;
|
||||||
private readonly queue = new PlayQueue();
|
private readonly queue = new PlayQueue();
|
||||||
@@ -92,13 +45,17 @@ export class Player {
|
|||||||
private context: PlayContext | null = null;
|
private context: PlayContext | null = null;
|
||||||
private state: PlayerState;
|
private state: PlayerState;
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private readonly device = deviceId();
|
private readonly persistence: PlaybackPersistence;
|
||||||
private lastServerSave = 0;
|
|
||||||
private preloaded: string | null = null;
|
private preloaded: string | null = null;
|
||||||
private refreshedFor: string | null = null;
|
private refreshedFor: string | null = null;
|
||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private readonly wavePolicy = new WaveCoordinator({
|
||||||
private waveLoading = false;
|
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;
|
private restoreVersion = 0;
|
||||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
onReaction: (() => void) | null = null;
|
onReaction: (() => void) | null = null;
|
||||||
@@ -107,10 +64,7 @@ export class Player {
|
|||||||
private readonly session: AuthSession,
|
private readonly session: AuthSession,
|
||||||
audio?: HTMLAudioElement,
|
audio?: HTMLAudioElement,
|
||||||
) {
|
) {
|
||||||
this.audio = audio ?? new Audio();
|
this.engine = new AudioEngine(audio);
|
||||||
this.audio.preload = 'auto';
|
|
||||||
this.overlap = new Audio();
|
|
||||||
this.overlap.preload = 'auto';
|
|
||||||
this.events = new EventBuffer({
|
this.events = new EventBuffer({
|
||||||
send: async (events) => {
|
send: async (events) => {
|
||||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { 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,
|
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,
|
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
|
||||||
};
|
};
|
||||||
|
this.persistence = new PlaybackPersistence(session, this.getState);
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.media = new MediaControls(this, this.getState, () => this.audio);
|
||||||
this.bindDesktopMedia();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
@@ -140,13 +94,12 @@ export class Player {
|
|||||||
|
|
||||||
getState = () => this.state;
|
getState = () => this.state;
|
||||||
|
|
||||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
private readonly media: MediaControls;
|
||||||
private desktopUnlisten: (() => void) | null = null;
|
|
||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
this.syncDesktopMedia();
|
this.media.syncDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncQueue() {
|
private syncQueue() {
|
||||||
@@ -183,16 +136,19 @@ export class Player {
|
|||||||
this.restoreVersion++;
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
|
const version = this.wavePolicy.version;
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
await this.session.client.POST('/api/v1/wave/sessions', {
|
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||||
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
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);
|
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
this.update({ error: 'wave.empty' });
|
this.update({ error: 'wave.empty' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.wavePolicy.activate(res.session_id);
|
||||||
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
|
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
|
||||||
this.queue.shuffle = false;
|
this.queue.shuffle = false;
|
||||||
this.queue.repeat = 'off';
|
this.queue.repeat = 'off';
|
||||||
@@ -210,12 +166,14 @@ export class Player {
|
|||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
if (!wave) return this.startWave(activity, mood);
|
if (!wave) return this.startWave(activity, mood);
|
||||||
this.update({ wave: { ...wave, activity, mood } });
|
this.update({ wave: { ...wave, activity, mood } });
|
||||||
|
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
params: { path: { id: wave.sessionId } },
|
params: { path: { id: wave.sessionId } },
|
||||||
body: { activity, mood },
|
body: { activity, mood },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
this.queue.dropUpcoming();
|
this.queue.dropUpcoming();
|
||||||
this.queue.append(fresh);
|
this.queue.append(fresh);
|
||||||
@@ -236,8 +194,8 @@ export class Player {
|
|||||||
|
|
||||||
private leaveWave() {
|
private leaveWave() {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
if (!wave) return;
|
if (!wave) return;
|
||||||
this.waveFeedback = [];
|
|
||||||
this.update({ wave: null });
|
this.update({ wave: null });
|
||||||
void this.session.client
|
void this.session.client
|
||||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
@@ -245,7 +203,7 @@ export class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
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) {
|
private async refillWave(refresh = false) {
|
||||||
const wave = this.state.wave;
|
await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
|
||||||
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; // волну уже выключили
|
|
||||||
if (refresh) this.queue.dropUpcoming();
|
if (refresh) this.queue.dropUpcoming();
|
||||||
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
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();
|
this.syncQueue();
|
||||||
} catch {
|
});
|
||||||
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
|
|
||||||
} finally {
|
|
||||||
this.waveLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
@@ -289,7 +230,7 @@ export class Player {
|
|||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
|
void this.engine.resume()?.catch(() => undefined);
|
||||||
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||||
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
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.
|
// 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.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||||
this.updateMediaMetadata(track);
|
this.media.updateMediaMetadata(track);
|
||||||
if (!autoplay) return;
|
if (!autoplay) return;
|
||||||
this.applyLoudness();
|
this.applyLoudness();
|
||||||
this.attempt = new PlayAttempt(track, startMs);
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
@@ -418,7 +359,8 @@ export class Player {
|
|||||||
void this.refillWave();
|
void this.refillWave();
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
try {
|
try {
|
||||||
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
|
const resumed = this.engine.resume();
|
||||||
|
if (resumed) await resumed;
|
||||||
await this.audio.play();
|
await this.audio.play();
|
||||||
} catch {
|
} catch {
|
||||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||||
@@ -465,73 +407,11 @@ export class Player {
|
|||||||
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
|
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
private applyLoudness(mix = 1) {
|
private applyLoudness(mix = 1) {
|
||||||
const track = this.state.track;
|
|
||||||
const user = this.state.muted ? 0 : this.state.volume;
|
const user = this.state.muted ? 0 : this.state.volume;
|
||||||
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
|
this.engine.applyLoudness(user * normalizeGain(this.state.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||||
@@ -590,7 +470,7 @@ export class Player {
|
|||||||
this.applyLoudness();
|
this.applyLoudness();
|
||||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
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.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(() => {
|
void this.audio.play().finally(() => {
|
||||||
this.fading = false;
|
this.fading = false;
|
||||||
this.ignoreEnded = false;
|
this.ignoreEnded = false;
|
||||||
@@ -599,13 +479,13 @@ export class Player {
|
|||||||
|
|
||||||
private bindAudio() {
|
private bindAudio() {
|
||||||
const a = this.audio;
|
const a = this.audio;
|
||||||
a.addEventListener('playing', () => {
|
this.engine.on('playing', () => {
|
||||||
this.update({ playing: true, loading: false });
|
this.update({ playing: true, loading: false });
|
||||||
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
|
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 });
|
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;
|
if (a.ended) return;
|
||||||
this.update({ playing: false });
|
this.update({ playing: false });
|
||||||
const t = this.queue.current;
|
const t = this.queue.current;
|
||||||
@@ -614,22 +494,22 @@ export class Player {
|
|||||||
this.saveServer();
|
this.saveServer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
a.addEventListener('waiting', () => {
|
this.engine.on('waiting', () => {
|
||||||
if (this.attempt) this.update({ loading: true });
|
if (this.attempt) this.update({ loading: true });
|
||||||
});
|
});
|
||||||
a.addEventListener('timeupdate', () => {
|
this.engine.on('timeupdate', () => {
|
||||||
const ms = Math.round(a.currentTime * 1000);
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
this.attempt?.tick(ms);
|
this.attempt?.tick(ms);
|
||||||
this.update({ positionMs: ms });
|
this.update({ positionMs: ms });
|
||||||
this.updatePositionState();
|
this.media.updatePositionState();
|
||||||
if (this.state.playing) {
|
if (this.state.playing) {
|
||||||
this.maybePreload(ms);
|
this.maybePreload(ms);
|
||||||
this.maybeCrossfade(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();
|
this.saveLocal();
|
||||||
});
|
});
|
||||||
a.addEventListener('ended', () => {
|
this.engine.on('ended', () => {
|
||||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||||
this.finishAttempt('play_end');
|
this.finishAttempt('play_end');
|
||||||
if (this.queue.next(true)) {
|
if (this.queue.next(true)) {
|
||||||
@@ -640,7 +520,7 @@ export class Player {
|
|||||||
this.saveServer();
|
this.saveServer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
a.addEventListener('error', () => void this.onError());
|
this.engine.on('error', () => void this.onError());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||||
@@ -683,138 +563,11 @@ export class Player {
|
|||||||
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
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 {
|
private saveLocal() { this.persistence.saveLocal(); }
|
||||||
const ids = this.queue.tracks.map((t) => t.id);
|
private saveSoon() { this.persistence.saveSoon(); }
|
||||||
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Уход со страницы: дослать события и состояние. */
|
/** Уход со страницы: дослать события и состояние. */
|
||||||
onHide() {
|
onHide() {
|
||||||
@@ -828,35 +581,7 @@ export class Player {
|
|||||||
*/
|
*/
|
||||||
async restore() {
|
async restore() {
|
||||||
const version = ++this.restoreVersion;
|
const version = ++this.restoreVersion;
|
||||||
let local: Saved | null = null;
|
const saved = await this.persistence.restore();
|
||||||
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 {
|
|
||||||
// офлайн — остаёмся с локальной копией
|
|
||||||
}
|
|
||||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
if (version !== this.restoreVersion) return;
|
if (version !== this.restoreVersion) return;
|
||||||
@@ -872,25 +597,21 @@ export class Player {
|
|||||||
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
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.
|
// Prepare at the saved position without creating a listening attempt.
|
||||||
await this.load(false, pos);
|
await this.load(false, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this.restoreVersion++;
|
this.restoreVersion++;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
this.fading = false;
|
this.fading = false;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.audio.pause();
|
this.engine.dispose();
|
||||||
this.audio.removeAttribute('src');
|
this.persistence.dispose();
|
||||||
this.audio.load();
|
|
||||||
this.overlap.pause();
|
|
||||||
this.overlap.removeAttribute('src');
|
|
||||||
this.overlap.load();
|
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
this.desktopUnlisten?.();
|
this.media.dispose();
|
||||||
this.desktopUnlisten = null;
|
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+28
-10
@@ -31,11 +31,11 @@
|
|||||||
| Сервис | Язык | Ответственность |
|
| Сервис | Язык | Ответственность |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
|
| `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` по внутренней сети. |
|
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
|
||||||
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
|
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
|
||||||
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
|
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
|
||||||
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
|
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, состояния upload/analysis jobs. |
|
||||||
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
|
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
|
||||||
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
|
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
### 3.3 Сканирование библиотеки
|
### 3.3 Сканирование библиотеки
|
||||||
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
|
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
|
||||||
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
|
2. `api` запускает `Scanner.Start` в процессе → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
|
||||||
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
|
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
|
||||||
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
|
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
|
||||||
|
|
||||||
@@ -94,11 +94,10 @@ pipu-music/
|
|||||||
│ │ ├── cmd/api/ # main сервиса
|
│ │ ├── cmd/api/ # main сервиса
|
||||||
│ │ ├── cmd/storage-agent/ # main агента хранилищ
|
│ │ ├── cmd/storage-agent/ # main агента хранилищ
|
||||||
│ │ ├── internal/
|
│ │ ├── internal/
|
||||||
│ │ │ ├── auth/ users/ library/ catalog/ search/
|
│ │ │ ├── auth/ library/ search/ genius/
|
||||||
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
|
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
|
||||||
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
|
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
|
||||||
│ │ ├── migrations/ # goose SQL-миграции
|
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
|
||||||
│ │ └── queries/ # sqlc SQL
|
|
||||||
│ └── recommender/ # Python (uv)
|
│ └── recommender/ # Python (uv)
|
||||||
│ ├── pipu_rec/
|
│ ├── pipu_rec/
|
||||||
│ │ ├── api/ # FastAPI
|
│ │ ├── api/ # FastAPI
|
||||||
@@ -120,16 +119,35 @@ pipu-music/
|
|||||||
## 5. Архитектурный стиль
|
## 5. Архитектурный стиль
|
||||||
|
|
||||||
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
|
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
|
||||||
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
|
(операции приложения) → `store` (ручной SQL через pgx). Узкие порты отделяют изменяемые операции
|
||||||
не лезут в чужие таблицы напрямую.
|
от адаптеров; миграция существующих модулей постепенная. Read-проекции могут делать JOIN между областями.
|
||||||
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
|
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
|
||||||
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
|
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
|
||||||
Сгенерированный код совпадает с контрактом.
|
Сгенерированный код совпадает с контрактом.
|
||||||
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
|
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
|
||||||
описан отдельными `internal-*.yaml` спеками.
|
реализован Go/Python DTO и обработчиками; отдельных `internal-*.yaml` сейчас нет.
|
||||||
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
|
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
|
||||||
что важно для офлайн-синхронизации мобильного.
|
что важно для офлайн-синхронизации мобильного.
|
||||||
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
|
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
|
||||||
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
|
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
|
||||||
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
|
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
|
||||||
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
|
метрики 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 — отдельное архитектурное решение при появлении
|
||||||
|
соответствующей потребности, не обязательная часть текущего рефакторинга.
|
||||||
@@ -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-код не менялись. Экономия токенов после рефакторинга ещё не измерена.
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check:boundaries": "node scripts/check-boundaries.mjs",
|
||||||
"icons": "node scripts/generate-icons.mjs",
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -25,3 +25,5 @@ export { normalizeGain, TARGET_LUFS } from './gain';
|
|||||||
export { trackShareUrl } from './share';
|
export { trackShareUrl } from './share';
|
||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
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';
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -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 сейчас не используются.
|
||||||
@@ -154,10 +154,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
|||||||
}
|
}
|
||||||
return libSvc.Root(src)
|
return libSvc.Root(src)
|
||||||
}
|
}
|
||||||
var dto library.DTO
|
pbStore := playback.NewStore(pool)
|
||||||
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
|
dto := library.DTO{Signer: signer, Personal: playback.NewPersonalizer(pbStore, signer)}
|
||||||
|
pb := playback.NewHandler(pbStore, libStore, dto, signer, transcoder,
|
||||||
rootFor, cfg.XAccel, log)
|
rootFor, cfg.XAccel, log)
|
||||||
dto = library.DTO{Signer: signer, Personal: pb}
|
|
||||||
libSvc.OnCatalogChanged = indexer.Trigger
|
libSvc.OnCatalogChanged = indexer.Trigger
|
||||||
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
||||||
go libSvc.Restore(ctx)
|
go libSvc.Restore(ctx)
|
||||||
@@ -169,7 +169,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
|||||||
upSvc.Run(ctx)
|
upSvc.Run(ctx)
|
||||||
|
|
||||||
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
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
|
wh.Warm = pb.Prewarm
|
||||||
return server.NewRouter(server.Deps{
|
return server.NewRouter(server.Deps{
|
||||||
Genius: genius.NewHandler(geniusSvc),
|
Genius: genius.NewHandler(geniusSvc),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -227,12 +228,77 @@ func TestLibraryAndUploads(t *testing.T) {
|
|||||||
assert.Empty(t, stale, "only uploading files expire")
|
assert.Empty(t, stale, "only uploading files expire")
|
||||||
up.Audit(ctx, &author, "upload.test", fileID, map[string]any{"ok": true})
|
up.Audit(ctx, &author, "upload.test", fileID, map[string]any{"ok": true})
|
||||||
|
|
||||||
// Импорт: связь с файлом библиотеки, место освобождено.
|
// A failure writing reviewer metadata must roll back both status and quota.
|
||||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusPendingReview}, uploads.StatusApproved, nil))
|
require.Error(t, up.Decide(ctx, fileID, uuid.New(), false, "invalid reviewer"))
|
||||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting, nil))
|
f, err = up.File(ctx, fileID)
|
||||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusImporting}, uploads.StatusImported,
|
require.NoError(t, err)
|
||||||
func(tx pgx.Tx) error { return uploads.MarkImported(ctx, tx, fileID, fileIDs[1]) }))
|
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)
|
used, _, err = up.Usage(ctx, author)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.EqualValues(t, 0, used)
|
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)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,25 +3,10 @@ package library
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"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/auth"
|
||||||
"pipu/api/internal/library/meta"
|
|
||||||
"pipu/api/internal/platform/apperr"
|
"pipu/api/internal/platform/apperr"
|
||||||
"pipu/api/internal/storageagent"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Коды ошибок библиотеки.
|
// Коды ошибок библиотеки.
|
||||||
@@ -45,24 +30,23 @@ type Config struct {
|
|||||||
HostRoot string // LIBRARY_HOST внутри контейнера, для local-источников
|
HostRoot string // LIBRARY_HOST внутри контейнера, для local-источников
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service управляет хранилищами и сканами.
|
// Service preserves the API while each use case owns only its dependencies.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
*SourceService
|
||||||
scanner *Scanner
|
*TemplateService
|
||||||
board *ProgressBoard
|
*ScanService
|
||||||
agent Agent
|
*Scheduler
|
||||||
prober Prober
|
|
||||||
cfg Config
|
|
||||||
log *slog.Logger
|
|
||||||
mu sync.Mutex // сериализует переподключение одного и того же источника
|
|
||||||
|
|
||||||
// OnCatalogChanged — каталог изменился не сканом (удалено хранилище): переиндексация поиска.
|
|
||||||
OnCatalogChanged func()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService создаёт сервис.
|
|
||||||
func NewService(store *Store, scanner *Scanner, board *ProgressBoard, agent Agent, prober Prober, cfg Config, log *slog.Logger) *Service {
|
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") }
|
func forbidden() error { return apperr.Forbidden(auth.CodeForbidden, "not allowed") }
|
||||||
@@ -84,746 +68,3 @@ func notFound(err error) error {
|
|||||||
}
|
}
|
||||||
return err
|
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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -34,11 +34,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Handler реализует playback- и collection-части gen.StrictServerInterface,
|
// Handler реализует playback- и collection-части gen.StrictServerInterface,
|
||||||
// а также library.Personalizer (ссылки на стрим и реакции в треках).
|
// персонализация каталога вынесена в Personalizer.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *Store
|
store *Store
|
||||||
catalog *library.Store
|
catalog *library.Store
|
||||||
dto func() library.DTO
|
dto library.DTO
|
||||||
signer *sign.Signer
|
signer *sign.Signer
|
||||||
transcoder *Transcoder
|
transcoder *Transcoder
|
||||||
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
|
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
|
||||||
@@ -48,8 +48,8 @@ type Handler struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик. dto — DTO каталога с персонализацией (замыкание: DTO ссылается на сам Handler).
|
// NewHandler создаёт обработчик. dto — независимо собранный DTO каталога с персонализацией.
|
||||||
func NewHandler(store *Store, catalog *library.Store, dto func() library.DTO, signer *sign.Signer, tr *Transcoder,
|
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,
|
rootFor func(context.Context, uuid.UUID) (string, error), xAccel bool, log *slog.Logger,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
@@ -66,34 +66,6 @@ func principal(ctx context.Context) (auth.Principal, error) {
|
|||||||
return p, nil
|
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.
|
// StreamTrack реализует gen.StrictServerInterface.
|
||||||
@@ -236,7 +208,7 @@ func (h *Handler) GetTracksBatch(ctx context.Context, req gen.GetTracksBatchRequ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items, err := h.dto().Tracks(ctx, rows)
|
items, err := h.dto.Tracks(ctx, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -251,74 +223,7 @@ func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObjec
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := h.now()
|
return (EventService{store: h.store, now: h.now}).Accept(ctx, p, req.Body.Events)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- состояние плеера ---
|
// --- состояние плеера ---
|
||||||
@@ -441,7 +346,7 @@ func (h *Handler) hydrate(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]g
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dtos, err := h.dto().Tracks(ctx, rows)
|
dtos, err := h.dto.Tracks(ctx, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -591,8 +496,6 @@ func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReac
|
|||||||
return gen.ClearTrackReaction204Response{}, nil
|
return gen.ClearTrackReaction204Response{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ library.Personalizer = (*Handler)(nil)
|
|
||||||
|
|
||||||
// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike.
|
// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike.
|
||||||
func impressionOutcome(eventType string, o Outcome) string {
|
func impressionOutcome(eventType string, o Outcome) string {
|
||||||
switch {
|
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)
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
dtos, err := h.dto().Tracks(ctx, tracks)
|
dtos, err := h.dto.Tracks(ctx, tracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
|
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
|
||||||
st := NewStore(pool)
|
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))
|
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
|
||||||
got, err := h.playlist(ctx, owner, id)
|
got, err := h.playlist(ctx, owner, id)
|
||||||
require.NoError(t, err)
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ package uploads
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -14,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
|
|
||||||
"pipu/api/internal/auth"
|
"pipu/api/internal/auth"
|
||||||
"pipu/api/internal/library"
|
"pipu/api/internal/library"
|
||||||
@@ -30,10 +27,10 @@ type Config struct {
|
|||||||
|
|
||||||
// Service — очередь на добавление.
|
// Service — очередь на добавление.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
lib *library.Store
|
lib SourceCatalog
|
||||||
libSvc *library.Service
|
*PlacementPlanner
|
||||||
scanner *library.Scanner
|
scanner ImportedScanner
|
||||||
agent library.Agent
|
agent library.Agent
|
||||||
prober library.Prober
|
prober library.Prober
|
||||||
fpcalc library.FPCalc
|
fpcalc library.FPCalc
|
||||||
@@ -46,10 +43,10 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService создаёт сервис очереди.
|
// 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 {
|
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
|
||||||
return &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),
|
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
}
|
}
|
||||||
@@ -233,7 +230,7 @@ func cleanClientPath(p *string) *string {
|
|||||||
|
|
||||||
// Complete — tus принял файл целиком: в обработку.
|
// Complete — tus принял файл целиком: в обработку.
|
||||||
func (s *Service) Complete(ctx context.Context, f File) error {
|
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 {
|
if err == nil {
|
||||||
wake(s.wakeProcess)
|
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 {
|
if err != nil || f.AuthorID != p.UserID {
|
||||||
return apperr.NotFound(CodeNotFound, "upload not found")
|
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)
|
return notFound(err)
|
||||||
}
|
}
|
||||||
s.removeStaging(f)
|
s.removeStaging(f)
|
||||||
@@ -324,14 +321,8 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Fil
|
|||||||
target = in.TargetSourceID
|
target = in.TargetSourceID
|
||||||
}
|
}
|
||||||
pl := s.plan(ctx, f, m, target)
|
pl := s.plan(ctx, f, m, target)
|
||||||
raw, _ := json.Marshal(m)
|
if err := s.store.UpdateMetadata(ctx, id, m, edited, pl); err != nil {
|
||||||
_, err = s.store.pool.Exec(ctx, `
|
return File{}, notFound(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return s.store.File(ctx, id)
|
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 {
|
if err != nil {
|
||||||
return notFound(err)
|
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 !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)
|
return notFound(err)
|
||||||
}
|
}
|
||||||
s.removeStaging(f)
|
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")
|
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
||||||
}
|
}
|
||||||
reason = ""
|
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)
|
return notFound(err)
|
||||||
}
|
}
|
||||||
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
|
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 {
|
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||||
return File{}, err
|
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)
|
return File{}, notFound(err)
|
||||||
}
|
}
|
||||||
wake(s.wakeImport)
|
wake(s.wakeImport)
|
||||||
@@ -495,102 +481,6 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
|
|||||||
return st, nil
|
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), иначе — по имени файла.
|
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
|
||||||
func extFor(f File) string {
|
func extFor(f File) string {
|
||||||
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
|
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
|
|
||||||
"pipu/api/internal/library"
|
"pipu/api/internal/library"
|
||||||
"pipu/api/internal/library/meta"
|
"pipu/api/internal/library/meta"
|
||||||
@@ -114,7 +112,7 @@ type outcome struct {
|
|||||||
func (s *Service) process(ctx context.Context, f File) error {
|
func (s *Service) process(ctx context.Context, f File) error {
|
||||||
abs := s.stagingAbs(f.StagingPath)
|
abs := s.stagingAbs(f.StagingPath)
|
||||||
o := s.analyze(ctx, f, abs)
|
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
|
return err
|
||||||
}
|
}
|
||||||
if o.status != StatusPendingReview {
|
if o.status != StatusPendingReview {
|
||||||
@@ -256,38 +254,6 @@ func (s *Service) sourcePatterns(ctx context.Context, id uuid.UUID) []meta.Patte
|
|||||||
return patterns
|
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 — работы нет.
|
// importNext импортирует один одобренный файл; false — работы нет.
|
||||||
@@ -300,17 +266,14 @@ func (s *Service) importNext(ctx context.Context) bool {
|
|||||||
s.log.Error("uploads: next to import", "err", err)
|
s.log.Error("uploads: next to import", "err", err)
|
||||||
return false
|
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)
|
return errors.Is(err, ErrWrongStatus)
|
||||||
}
|
}
|
||||||
if err := s.importFile(ctx, f); err != nil {
|
if err := s.importFile(ctx, f); err != nil {
|
||||||
s.log.Warn("upload import failed", "upload_id", f.ID, "err", err)
|
s.log.Warn("upload import failed", "upload_id", f.ID, "err", err)
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
if ferr := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImportFailed, func(tx pgx.Tx) error {
|
if ferr := s.store.FailImport(ctx, f.ID, CodeImportFailed+": "+msg); ferr != nil {
|
||||||
_, err := tx.Exec(ctx, `UPDATE upload_files SET status_reason = $2 WHERE id = $1`, f.ID, CodeImportFailed+": "+msg)
|
|
||||||
return err
|
|
||||||
}); ferr != nil {
|
|
||||||
s.log.Error("uploads: mark import failed", "upload_id", f.ID, "err", ferr)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("scan: %w", err)
|
return fmt.Errorf("scan: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.store.Transition(ctx, f.ID, []Status{StatusImporting}, StatusImported, func(tx pgx.Tx) error {
|
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.removeStaging(f)
|
s.removeStaging(f)
|
||||||
@@ -439,7 +396,7 @@ func (s *Service) expire(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
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)
|
s.removeStaging(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +404,7 @@ func (s *Service) expire(ctx context.Context) {
|
|||||||
|
|
||||||
// reconcile при старте: прерванный импорт — снова в approved; осиротевшие файлы staging удаляются.
|
// reconcile при старте: прерванный импорт — снова в approved; осиротевшие файлы staging удаляются.
|
||||||
func (s *Service) reconcile(ctx context.Context) {
|
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)
|
s.log.Error("uploads: reset importing", "err", err)
|
||||||
}
|
}
|
||||||
inUse, err := s.store.StagingInUse(ctx)
|
inUse, err := s.store.StagingInUse(ctx)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
|
|||||||
artists = append(artists, a)
|
artists = append(artists, a)
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
dto := h.dto()
|
dto := h.dto
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
item := struct {
|
item := struct {
|
||||||
CoverColors []string `json:"cover_colors"`
|
CoverColors []string `json:"cover_colors"`
|
||||||
|
|||||||
@@ -139,14 +139,14 @@ type Handler struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
rec *Recommender
|
rec *Recommender
|
||||||
catalog *library.Store
|
catalog *library.Store
|
||||||
dto func() library.DTO
|
dto library.DTO
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
|
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
|
||||||
Warm func(ctx context.Context, trackIDs []uuid.UUID)
|
Warm func(ctx context.Context, trackIDs []uuid.UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик.
|
// 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}
|
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 {
|
if err != nil {
|
||||||
return gen.WaveBatch{}, err
|
return gen.WaveBatch{}, err
|
||||||
}
|
}
|
||||||
tracks, err := h.dto().Tracks(ctx, rows)
|
tracks, err := h.dto.Tracks(ctx, rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return gen.WaveBatch{}, err
|
return gen.WaveBatch{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user