From e158558da3391cf877cf21924e9ec89c2c2ffe9c Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:52:16 +0300 Subject: [PATCH] refactor: isolate playback and upload use cases --- .gitea/workflows/ci-boundaries.yml | 27 + AGENTS.md | 10 + README.md | 2 + apps/mobile/AGENTS.md | 7 + apps/mobile/src/player/controller.ts | 81 +- apps/mobile/test/controller.test.ts | 72 ++ apps/web/AGENTS.md | 8 + .../src/features/uploads/BatchEditDialog.tsx | 69 ++ .../src/features/uploads/EditDialog.test.tsx | 56 ++ apps/web/src/features/uploads/EditDialog.tsx | 85 ++ apps/web/src/features/uploads/QueueRows.tsx | 153 ++++ .../web/src/features/uploads/RejectDialog.tsx | 40 + apps/web/src/features/uploads/UploadQueue.tsx | 180 ++++ .../uploads/UploadSettingsSection.tsx | 101 +++ apps/web/src/features/uploads/api.ts | 26 + apps/web/src/features/uploads/common.tsx | 84 ++ apps/web/src/features/uploads/queries.ts | 27 + .../web/src/pages/admin/AdminSettingsPage.tsx | 2 +- apps/web/src/pages/admin/UploadQueuePage.tsx | 641 +------------- apps/web/src/pages/uploads/common.tsx | 85 +- apps/web/src/player/analysis.ts | 31 + apps/web/src/player/audio.test.ts | 47 +- apps/web/src/player/engine.ts | 66 ++ apps/web/src/player/media.ts | 105 +++ apps/web/src/player/persistence.ts | 127 +++ apps/web/src/player/player.ts | 399 ++------- apps/web/src/player/types.ts | 18 + docs/architecture.md | 38 +- docs/reviews/2026-10-22-review-1.md | 184 ++++ package.json | 1 + packages/core/AGENTS.md | 6 + packages/core/src/index.ts | 2 + packages/core/src/wave.test.ts | 76 ++ packages/core/src/wave.ts | 62 ++ scripts/check-boundaries.mjs | 47 ++ services/api/AGENTS.md | 9 + services/api/cmd/api/main.go | 8 +- .../api/internal/dbtest/integration_test.go | 76 +- services/api/internal/library/scans.go | 69 ++ services/api/internal/library/scans_test.go | 55 ++ services/api/internal/library/schedule.go | 118 +++ services/api/internal/library/service.go | 785 +----------------- services/api/internal/library/sources.go | 464 +++++++++++ services/api/internal/library/templates.go | 239 ++++++ services/api/internal/playback/events.go | 88 ++ services/api/internal/playback/handler.go | 111 +-- .../api/internal/playback/personalizer.go | 58 ++ .../internal/playback/personalizer_test.go | 55 ++ services/api/internal/playback/playlists.go | 2 +- .../playback/playlists_integration_test.go | 2 +- services/api/internal/uploads/persistence.go | 99 +++ services/api/internal/uploads/placement.go | 131 +++ services/api/internal/uploads/service.go | 136 +-- services/api/internal/uploads/worker.go | 55 +- services/api/internal/wave/onboarding.go | 2 +- services/api/internal/wave/wave.go | 6 +- 56 files changed, 3337 insertions(+), 2196 deletions(-) create mode 100644 .gitea/workflows/ci-boundaries.yml create mode 100644 AGENTS.md create mode 100644 apps/mobile/AGENTS.md create mode 100644 apps/mobile/test/controller.test.ts create mode 100644 apps/web/AGENTS.md create mode 100644 apps/web/src/features/uploads/BatchEditDialog.tsx create mode 100644 apps/web/src/features/uploads/EditDialog.test.tsx create mode 100644 apps/web/src/features/uploads/EditDialog.tsx create mode 100644 apps/web/src/features/uploads/QueueRows.tsx create mode 100644 apps/web/src/features/uploads/RejectDialog.tsx create mode 100644 apps/web/src/features/uploads/UploadQueue.tsx create mode 100644 apps/web/src/features/uploads/UploadSettingsSection.tsx create mode 100644 apps/web/src/features/uploads/api.ts create mode 100644 apps/web/src/features/uploads/common.tsx create mode 100644 apps/web/src/features/uploads/queries.ts create mode 100644 apps/web/src/player/analysis.ts create mode 100644 apps/web/src/player/engine.ts create mode 100644 apps/web/src/player/media.ts create mode 100644 apps/web/src/player/persistence.ts create mode 100644 apps/web/src/player/types.ts create mode 100644 docs/reviews/2026-10-22-review-1.md create mode 100644 packages/core/AGENTS.md create mode 100644 packages/core/src/wave.test.ts create mode 100644 packages/core/src/wave.ts create mode 100644 scripts/check-boundaries.mjs create mode 100644 services/api/AGENTS.md create mode 100644 services/api/internal/library/scans.go create mode 100644 services/api/internal/library/scans_test.go create mode 100644 services/api/internal/library/schedule.go create mode 100644 services/api/internal/library/sources.go create mode 100644 services/api/internal/library/templates.go create mode 100644 services/api/internal/playback/events.go create mode 100644 services/api/internal/playback/personalizer.go create mode 100644 services/api/internal/playback/personalizer_test.go create mode 100644 services/api/internal/uploads/persistence.go create mode 100644 services/api/internal/uploads/placement.go diff --git a/.gitea/workflows/ci-boundaries.yml b/.gitea/workflows/ci-boundaries.yml new file mode 100644 index 0000000..5f882e3 --- /dev/null +++ b/.gitea/workflows/ci-boundaries.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b479a8 --- /dev/null +++ b/AGENTS.md @@ -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`. +- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы. diff --git a/README.md b/README.md index 3bcbc23..800fec0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro - Идея продукта: [docs/IDEA.md](docs/IDEA.md) - Документация: [docs/README.md](docs/README.md) +- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md) - Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md) ## Структура @@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev ```bash pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts +pnpm check:boundaries ``` ```bash diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md new file mode 100644 index 0000000..add78b0 --- /dev/null +++ b/apps/mobile/AGENTS.md @@ -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-тесты не заменяют эту проверку. diff --git a/apps/mobile/src/player/controller.ts b/apps/mobile/src/player/controller.ts index a46bc8a..10718c1 100644 --- a/apps/mobile/src/player/controller.ts +++ b/apps/mobile/src/player/controller.ts @@ -1,6 +1,11 @@ import NetInfo from '@react-native-community/netinfo'; import { EventBuffer, + WaveCoordinator, + EARLY_SKIP_MS, + type WaveInfo, + type WaveOutcome, + type WaveFeedback, PlayAttempt, normalizeGain, unwrap, @@ -24,12 +29,7 @@ import { eventId } from './eventId'; export { offlineWave }; -export interface WaveInfo { - sessionId: string; - activity: string | null; - mood: string | null; - fallback: boolean; -} +export type { WaveInfo } from '@pipu/core'; export interface PlayerState { ready: boolean; @@ -44,10 +44,6 @@ export interface PlayerState { wave: WaveInfo | null; } -type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike'; - -const WAVE_REFILL_AT = 2; -const EARLY_SKIP_MS = 30_000; const SAVE_EVERY_MS = 10_000; const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id)); @@ -84,8 +80,13 @@ class Controller { private attempt: PlayAttempt | null = null; private lastPositionMs = 0; private lastSave = 0; - private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; - private waveLoading = false; + private readonly wavePolicy = new WaveCoordinator({ + next: async (id: string, feedback: WaveFeedback[]) => unwrap( + await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', { + params: { path: { id } }, body: { feedback }, + }), + ), + }); private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны private setupDone: Promise | null = null; private listeners = new Set<() => void>(); @@ -308,18 +309,22 @@ class Controller { if (!this.session) return; await this.setup(); this.leaveWave(); + const version = this.wavePolicy.version; let res; try { res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } })); } catch (e) { + if (!this.wavePolicy.isCurrent(version)) return; // Нет связи с сервером — офлайн-волна из скачанного. const local = offlineWave(downloads.downloaded()); if (!local.length) throw e; return this.playList(local, 0, { type: 'collection', id: 'downloads' }); } + if (!this.wavePolicy.isCurrent(version)) return; const tracks = res.tracks.filter(playable); if (!tracks.length) return; this.finishAttempt(this.lastPositionMs / 1000); + this.wavePolicy.activate(res.session_id); this.update({ queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id }, wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback }, @@ -332,13 +337,16 @@ class Controller { async setWaveContext(activity: string | null, mood: string | null) { const wave = this.state.wave; if (!wave || !this.session) return this.startWave(activity, mood); + const version = this.wavePolicy.changeContext(wave.sessionId); const res = unwrap( await this.session.client.PATCH('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } }, body: { activity, mood }, }), ); - await this.replaceUpcoming(res.tracks); + if (!this.wavePolicy.isCurrent(version)) return; + await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version)); + if (!this.wavePolicy.isCurrent(version)) return; this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); // Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе). if (res.tracks.some(playable)) { @@ -351,8 +359,8 @@ class Controller { private leaveWave() { const wave = this.state.wave; + this.wavePolicy.activate(null); if (!wave) return; - this.waveFeedback = []; this.update({ wave: null }); void this.session?.client .DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } }) @@ -360,46 +368,36 @@ class Controller { } private noteWave(trackId: string, outcome: WaveOutcome) { - if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome }); + this.wavePolicy.note(trackId, outcome); } /** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */ - private async replaceUpcoming(tracks: Track[]) { + private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) { const keep = this.state.queue.slice(0, this.state.index + 1); - await TrackPlayer.removeUpcomingTracks(); const add = tracks.filter(playable); - await TrackPlayer.add(await this.toRntp(add)); - this.update({ queue: [...keep, ...add] }); + const native = await this.toRntp(add); + if (!current()) return; + await TrackPlayer.removeUpcomingTracks(); + if (!current()) return; + await TrackPlayer.add(native); + if (current()) this.update({ queue: [...keep, ...add] }); } private async refillWave(refresh = false) { - const wave = this.state.wave; - if (!wave || !this.session || this.waveLoading) return; + if (!this.session) return; const remaining = this.state.queue.length - this.state.index - 1; - if (!refresh && remaining > WAVE_REFILL_AT) return; - this.waveLoading = true; - const feedback = this.waveFeedback; - this.waveFeedback = []; - try { - const res = unwrap( - await this.session.client.POST('/api/v1/wave/sessions/{id}/next', { - params: { path: { id: wave.sessionId } }, - body: { feedback }, - }), - ); - if (this.state.wave?.sessionId !== wave.sessionId) return; - if (refresh) await this.replaceUpcoming(res.tracks); + await this.wavePolicy.refill(remaining, refresh, async (res, current) => { + if (refresh) await this.replaceUpcoming(res.tracks, current); else { const add = res.tracks.filter(playable); - await TrackPlayer.add(await this.toRntp(add)); + const native = await this.toRntp(add); + if (!current()) return; + await TrackPlayer.add(native); + if (!current()) return; this.update({ queue: [...this.state.queue, ...add] }); } - this.update({ wave: { ...wave, fallback: res.fallback } }); - } catch { - this.waveFeedback = [...feedback, ...this.waveFeedback]; - } finally { - this.waveLoading = false; - } + if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } }); + }); } // --- состояние --- @@ -443,6 +441,7 @@ class Controller { } async reset() { + this.wavePolicy.activate(null); this.attempt = null; this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null }); if (this.setupDone) await TrackPlayer.reset().catch(() => undefined); diff --git a/apps/mobile/test/controller.test.ts b/apps/mobile/test/controller.test.ts new file mode 100644 index 0000000..eaf0ec0 --- /dev/null +++ b/apps/mobile/test/controller.test.ts @@ -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() { + let resolve!: (v: T) => void; + const promise = new Promise((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>(); + 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>(); + 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'); +}); diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..565ad07 --- /dev/null +++ b/apps/web/AGENTS.md @@ -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 должен освобождать обработчики. diff --git a/apps/web/src/features/uploads/BatchEditDialog.tsx b/apps/web/src/features/uploads/BatchEditDialog.tsx new file mode 100644 index 0000000..4816064 --- /dev/null +++ b/apps/web/src/features/uploads/BatchEditDialog.tsx @@ -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(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 ( + + + + + } + > +
+

{t('uploads.batch_edit_hint')}

+ + + + + + {save.error ? errorText(save.error) : null} +
+
+ ); +} diff --git a/apps/web/src/features/uploads/EditDialog.test.tsx b/apps/web/src/features/uploads/EditDialog.test.tsx new file mode 100644 index 0000000..1d56892 --- /dev/null +++ b/apps/web/src/features/uploads/EditDialog.test.tsx @@ -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( + + ); + 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(); +}); diff --git a/apps/web/src/features/uploads/EditDialog.tsx b/apps/web/src/features/uploads/EditDialog.tsx new file mode 100644 index 0000000..7d51e65 --- /dev/null +++ b/apps/web/src/features/uploads/EditDialog.tsx @@ -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(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') => ( + setM({ ...m, [k]: v })} maxLength={500} /> + ); + const num = (k: 'year' | 'track' | 'disc') => ( + setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })} + /> + ); + const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? []; + return ( + + + + + } + > +
+

{file.client_rel_path ?? file.original_name}

+
+ {text('artist')} + {text('albumartist')} + {text('album')} + {text('title')} + {text('genre')} + {num('year')} + {num('track')} + {num('disc')} +
+ +

{t('uploads.tags_note')}

+ {save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null} +
+
+ ); +} diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx new file mode 100644 index 0000000..cbc5ca1 --- /dev/null +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -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; + 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 ( +
+
+ {selectable && ( + onToggle(ids, e.target.checked)} /> + )} + {first.author.username} + {formatDateTime(first.created_at, i18n.language)} + {t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })} + {first.batch_comment && «{first.batch_comment}»} + + {selectable && ( + + )} +
+ {files.map((f) => ( + onToggle([f.id], on)} + onEdit={() => onEdit(f)} + onRetry={() => onRetry(f.id)} + /> + ))} +
+ ); +} + +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 ( +
+ {selectable ? ( + onCheck(e.target.checked)} /> + ) : ( + + )} +
+ {m ? `${m.artist} — ${m.title}` : f.original_name} + {m && ( + + {[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')}`} + + )} + {quality} + + {f.client_rel_path ?? f.original_name} + +
+
+
+ + {needsFormatAttention(f.format, f.original_name) && ( + + {t('uploads.flag_attention')} + + )} + {f.possible_duplicate && {t('uploads.flag_possible')}} + {f.quality_upgrade && {t('uploads.flag_quality')}} + {f.matched_album_id ? ( + {t('uploads.flag_existing_album')} + ) : ( + m && {t('uploads.flag_new_album')} + )} +
+ {f.target_rel_path && {f.target_rel_path}} + {reason && {reason}} + {f.duplicate_level && f.status === 'duplicate' && ( + {t(`uploads.dup_level_${f.duplicate_level}`)} + )} +
+
+ {f.stream_url && ( +
+
+ ); +} diff --git a/apps/web/src/features/uploads/RejectDialog.tsx b/apps/web/src/features/uploads/RejectDialog.tsx new file mode 100644 index 0000000..754b833 --- /dev/null +++ b/apps/web/src/features/uploads/RejectDialog.tsx @@ -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 ( + + + + + } + > +