From 066ba24834a11e3103b9031e56d7419f61362f6e Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:37:53 +0300 Subject: [PATCH] feat: finish the upload, playback, and test gaps from the docs Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor --- .gitea/workflows/ci-ts.yml | 2 + .gitea/workflows/nightly-e2e.yml | 37 + .gitignore | 2 + api/openapi.yaml | 126 ++- apps/mobile/.maestro/collection.yaml | 5 + apps/mobile/.maestro/login.yaml | 15 + apps/mobile/.maestro/update.yaml | 6 + apps/mobile/package.json | 6 +- apps/mobile/src/app/(tabs)/profile.tsx | 10 + apps/mobile/src/lib/releaseNeed.ts | 7 + apps/mobile/src/lib/update.ts | 7 +- apps/mobile/src/player/controller.ts | 29 +- apps/mobile/src/player/offlineWave.ts | 10 + apps/mobile/test/screens.test.ts | 38 + apps/mobile/vitest.config.ts | 7 + apps/web/e2e/scenarios.spec.ts | 54 + apps/web/package.json | 7 +- apps/web/playwright.config.ts | 13 + apps/web/src/App.test.tsx | 17 + apps/web/src/app/router.tsx | 19 +- apps/web/src/entities/catalog.tsx | 176 ++-- apps/web/src/features/uploads/uploader.ts | 152 ++- apps/web/src/main.tsx | 4 + apps/web/src/pages/SettingsPage.tsx | 26 +- apps/web/src/pages/admin/UploadQueuePage.tsx | 102 +- apps/web/src/pages/uploads/MyUploadsPage.tsx | 8 +- apps/web/src/pages/uploads/SuggestPage.tsx | 19 +- apps/web/src/player/player.ts | 117 ++- apps/web/src/test/e2eBridge.ts | 15 + apps/web/src/test/fakeApi.ts | 10 + apps/web/src/widgets/AppShell.tsx | 23 +- apps/web/vite.config.ts | 1 + docs/data-model.md | 12 +- docs/design.md | 12 +- docs/playback.md | 3 +- docs/testing.md | 15 +- docs/uploads.md | 22 +- packages/api-client/src/generated/schema.ts | 191 +++- packages/core/src/gain.test.ts | 14 + packages/core/src/gain.ts | 9 + packages/core/src/index.ts | 3 +- packages/i18n/locales/en/admin.json | 16 +- packages/i18n/locales/en/common.json | 4 +- packages/i18n/locales/ru/admin.json | 16 +- packages/i18n/locales/ru/common.json | 4 +- pnpm-lock.yaml | 262 ++++- services/api/cmd/api/main.go | 8 +- services/api/internal/api/gen/api.gen.go | 990 +++++++++++++----- services/api/internal/playback/handler.go | 32 + services/api/internal/uploads/batch.go | 141 +++ services/api/internal/uploads/handler.go | 85 +- services/api/internal/uploads/model.go | 11 +- services/api/internal/uploads/quality.go | 31 + services/api/internal/uploads/quality_test.go | 21 + services/api/internal/uploads/service.go | 34 +- services/api/internal/uploads/store.go | 96 +- services/api/internal/uploads/stream.go | 92 ++ services/api/internal/uploads/worker.go | 53 +- services/api/internal/wave/wave.go | 5 + services/api/migrations/00011_upload_rest.sql | 14 + tests/contract/schemathesis.sh | 15 + 61 files changed, 2818 insertions(+), 463 deletions(-) create mode 100644 .gitea/workflows/nightly-e2e.yml create mode 100644 apps/mobile/.maestro/collection.yaml create mode 100644 apps/mobile/.maestro/login.yaml create mode 100644 apps/mobile/.maestro/update.yaml create mode 100644 apps/mobile/src/lib/releaseNeed.ts create mode 100644 apps/mobile/src/player/offlineWave.ts create mode 100644 apps/mobile/test/screens.test.ts create mode 100644 apps/mobile/vitest.config.ts create mode 100644 apps/web/e2e/scenarios.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/src/test/e2eBridge.ts create mode 100644 packages/core/src/gain.test.ts create mode 100644 packages/core/src/gain.ts create mode 100644 services/api/internal/uploads/batch.go create mode 100644 services/api/internal/uploads/quality.go create mode 100644 services/api/internal/uploads/quality_test.go create mode 100644 services/api/internal/uploads/stream.go create mode 100644 services/api/migrations/00011_upload_rest.sql create mode 100644 tests/contract/schemathesis.sh diff --git a/.gitea/workflows/ci-ts.yml b/.gitea/workflows/ci-ts.yml index f3175a9..3f13014 100644 --- a/.gitea/workflows/ci-ts.yml +++ b/.gitea/workflows/ci-ts.yml @@ -51,3 +51,5 @@ jobs: - run: pnpm typecheck - run: pnpm test - run: pnpm --filter @pipu/web build + - run: pnpm --filter @pipu/web exec playwright install chromium + - run: pnpm --filter @pipu/web test:e2e diff --git a/.gitea/workflows/nightly-e2e.yml b/.gitea/workflows/nightly-e2e.yml new file mode 100644 index 0000000..641a4de --- /dev/null +++ b/.gitea/workflows/nightly-e2e.yml @@ -0,0 +1,37 @@ +# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются. +name: nightly / e2e + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +jobs: + schemathesis: + name: schemathesis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pip install schemathesis + - name: Фаззинг API + env: + PIPU_API_URL: ${{ vars.PIPU_API_URL }} + PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }} + run: | + if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi + sh tests/contract/schemathesis.sh + + maestro: + name: maestro + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Сценарии Android + env: + PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }} + PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }} + PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }} + run: | + if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi + curl -Ls "https://get.maestro.mobile.dev" | bash + ~/.maestro/bin/maestro test apps/mobile/.maestro diff --git a/.gitignore b/.gitignore index 223099f..ee206bb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ __pycache__/ # generated test audio tests/fixtures/music/out/ +apps/web/test-results/ +apps/web/playwright-report/ # os / editors .DS_Store diff --git a/api/openapi.yaml b/api/openapi.yaml index d4039f1..00a3df3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -451,6 +451,35 @@ paths: $ref: "#/components/schemas/UploadFilePage" default: $ref: "#/components/responses/Problem" + /api/v1/uploads/unseen: + get: + operationId: unseenUploadDecisions + tags: [uploads] + summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin) + responses: + "200": + description: Число + content: + application/json: + schema: + type: object + required: [count] + properties: + count: + type: integer + minimum: 0 + default: + $ref: "#/components/responses/Problem" + /api/v1/uploads/seen: + post: + operationId: markUploadsSeen + tags: [uploads] + summary: Отметить свои предложения просмотренными + responses: + "204": + description: Отмечено + default: + $ref: "#/components/responses/Problem" /api/v1/uploads/files/{id}: parameters: - $ref: "#/components/parameters/EntityId" @@ -535,6 +564,65 @@ paths: $ref: "#/components/schemas/UploadFile" default: $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/files/{id}/stream: + get: + operationId: streamUpload + tags: [uploads] + security: [] + summary: Прослушать файл из staging (подпись, как у стрима трека) + description: Ссылку даёт `stream_url` файла очереди. Range поддерживается. + parameters: + - $ref: "#/components/parameters/EntityId" + - name: exp + in: query + required: true + schema: + type: integer + format: int64 + - name: sig + in: query + required: true + schema: + type: string + responses: + "200": + description: Аудио из staging + content: + audio/*: + schema: + type: string + format: binary + default: + $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/batches/{id}: + parameters: + - $ref: "#/components/parameters/EntityId" + patch: + operationId: updateUploadBatch + tags: [uploads] + summary: Правка метаданных и обложки на всю пачку (admin) + description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UploadBatchPatch" + responses: + "200": + description: Обновлённые файлы пачки + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UploadFile" + default: + $ref: "#/components/responses/Problem" /api/v1/admin/uploads/review: post: operationId: reviewUploads @@ -1620,6 +1708,9 @@ components: type: string duration_ms: type: integer + album: + type: string + maxLength: 500 UploadPrecheckResult: type: object required: [files] @@ -1733,7 +1824,7 @@ components: nullable: true UploadFile: type: object - required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url] + required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url] properties: id: type: string @@ -1807,6 +1898,9 @@ components: enum: [sha256, metadata, fingerprint, null] possible_duplicate: type: boolean + quality_upgrade: + type: boolean + description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade target_source_id: type: string format: uuid @@ -1832,6 +1926,10 @@ components: type: string nullable: true description: Только автору и пока файл в статусе uploading + stream_url: + type: string + nullable: true + description: Подписанная ссылка прослушивания из staging; null, если файла уже нет UploadFilePage: type: object required: [items, next_cursor] @@ -1851,6 +1949,23 @@ components: target_source_id: type: string format: uuid + UploadBatchPatch: + type: object + description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. + properties: + artist: + type: string + maxLength: 500 + album: + type: string + maxLength: 500 + year: + type: integer + nullable: true + cover_base64: + type: string + nullable: true + description: JPEG, PNG или WebP, не больше 8 МБ после декодирования UploadReviewRequest: type: object required: [file_ids, decision] @@ -1890,7 +2005,7 @@ components: - $ref: "#/components/schemas/Problem" UploadSettings: type: object - required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id] + required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade] properties: queue_limit_bytes: type: integer @@ -1906,6 +2021,9 @@ components: type: string format: uuid nullable: true + allow_quality_upgrade: + type: boolean + description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли UploadSettingsPatch: type: object description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). @@ -1929,6 +2047,8 @@ components: format: uuid nullable: true description: null — без хранилища по умолчанию + allow_quality_upgrade: + type: boolean HealthStatus: type: object required: [status] @@ -2036,7 +2156,7 @@ components: description: Где в топбаре веба поиск (по умолчанию center) search_collapse: type: boolean - description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) + description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) home_activities: type: boolean description: Показывать на главной веба выбор занятия (по умолчанию true) diff --git a/apps/mobile/.maestro/collection.yaml b/apps/mobile/.maestro/collection.yaml new file mode 100644 index 0000000..cd08384 --- /dev/null +++ b/apps/mobile/.maestro/collection.yaml @@ -0,0 +1,5 @@ +appId: app.pipu.music +--- +- runFlow: login.yaml +- tapOn: "Коллекция" +- assertVisible: "Лайки" diff --git a/apps/mobile/.maestro/login.yaml b/apps/mobile/.maestro/login.yaml new file mode 100644 index 0000000..af7a58f --- /dev/null +++ b/apps/mobile/.maestro/login.yaml @@ -0,0 +1,15 @@ +appId: app.pipu.music +--- +- launchApp: + clearState: true +- assertVisible: "Адрес сервера" +- tapOn: "Адрес сервера" +- inputText: ${PIPU_E2E_SERVER} +- tapOn: "Подключиться" +- assertVisible: "Вход" +- tapOn: "Логин" +- inputText: ${PIPU_E2E_USER} +- tapOn: "Пароль" +- inputText: ${PIPU_E2E_PASSWORD} +- tapOn: "Войти" +- assertVisible: "Бегаю" diff --git a/apps/mobile/.maestro/update.yaml b/apps/mobile/.maestro/update.yaml new file mode 100644 index 0000000..022940f --- /dev/null +++ b/apps/mobile/.maestro/update.yaml @@ -0,0 +1,6 @@ +appId: app.pipu.music +--- +- runFlow: login.yaml +- tapOn: "Профиль" +- assertVisible: "Проверить обновления" +- tapOn: "Проверить обновления" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f0ee198..45576c5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,7 +7,8 @@ "start": "expo start --dev-client", "android": "expo run:android", "prebuild": "expo prebuild --platform android --clean", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@expo/vector-icons": "^14.1.0", @@ -42,7 +43,8 @@ }, "devDependencies": { "@types/node": "^22.20.5", - "@types/react": "~19.0.10" + "@types/react": "~19.0.10", + "vitest": "^3.2.7" }, "expo": { "doctor": { diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index bb27ffe..3cd3796 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -116,6 +116,16 @@ export default function ProfileScreen() { onChange={(v) => void setPrefs({ mobile_player: v })} /> + + void setPrefs({ normalize: v })} /> + + + ({ value: v, label: v }))} + onChange={(v) => void setPrefs({ crossfade_s: Number(v) })} + /> +
diff --git a/apps/mobile/src/lib/releaseNeed.ts b/apps/mobile/src/lib/releaseNeed.ts new file mode 100644 index 0000000..9fff561 --- /dev/null +++ b/apps/mobile/src/lib/releaseNeed.ts @@ -0,0 +1,7 @@ +export type ReleaseNeed = 'current' | 'optional' | 'required'; + +/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */ +export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed { + if (versionCode <= installed) return 'current'; + return installed < minSupported ? 'required' : 'optional'; +} diff --git a/apps/mobile/src/lib/update.ts b/apps/mobile/src/lib/update.ts index d80615f..518a56a 100644 --- a/apps/mobile/src/lib/update.ts +++ b/apps/mobile/src/lib/update.ts @@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher'; import * as SecureStore from 'expo-secure-store'; import { sha256 } from 'js-sha256'; +import { releaseNeed } from './releaseNeed'; + export type AndroidRelease = components['schemas']['AndroidRelease']; /** Состояние самообновления (docs/mobile.md#самообновление). */ @@ -96,11 +98,12 @@ class Updater { const release = unwrap(await session.client.GET('/api/v1/app/android/latest')); await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now())); const installed = installedVersionCode(); - if (release.version_code > installed) { + const need = releaseNeed(installed, release.version_code, release.min_supported); + if (need !== 'current') { this.set({ status: 'available', release, - required: installed < release.min_supported, + required: need === 'required', dismissed: false, }); } else { diff --git a/apps/mobile/src/player/controller.ts b/apps/mobile/src/player/controller.ts index f51c396..33beb44 100644 --- a/apps/mobile/src/player/controller.ts +++ b/apps/mobile/src/player/controller.ts @@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo'; import { EventBuffer, PlayAttempt, + normalizeGain, unwrap, type AuthSession, type PlayContext, @@ -18,6 +19,9 @@ import TrackPlayer, { import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { downloads } from '../offline/downloads'; import { PersistentEvents } from '../offline/events'; +import { offlineWave } from './offlineWave'; + +export { offlineWave }; export interface WaveInfo { sessionId: string; @@ -45,15 +49,6 @@ const WAVE_REFILL_AT = 2; const EARLY_SKIP_MS = 30_000; const SAVE_EVERY_MS = 10_000; -/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */ -export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] { - return tracks - .filter((t) => t.reaction !== 'dislike') - .map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) })) - .sort((a, b) => a.key - b.key) - .map((x) => x.t); -} - const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id)); /** Ссылки API относительные — для приложения нужен полный адрес сервера. */ @@ -159,6 +154,10 @@ class Controller { this.lastPositionMs = ms; this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs }); if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save(); + const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0)); + const left = duration - position; + const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1; + void this.applyVolume(this.state.track, fade); }); TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => { this.finishAttempt(lastPosition); @@ -167,6 +166,7 @@ class Controller { if (!track) return; this.attempt = new PlayAttempt(track, 0); this.update({ track, index, positionMs: 0, durationMs: track.duration_ms }); + void this.applyVolume(track, 1); this.events?.push({ type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type, context_id: this.state.context?.id, @@ -209,6 +209,17 @@ class Controller { } } + private userPrefs() { + const st = this.session?.state; + return st?.status === 'signed_in' ? st.user.ui_prefs : undefined; + } + + /** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */ + private async applyVolume(track: Track | null, fade: number) { + const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade); + await TrackPlayer.setVolume(gain).catch(() => undefined); + } + private async toRntp(tracks: Track[]): Promise { const q = await quality(); // Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети. diff --git a/apps/mobile/src/player/offlineWave.ts b/apps/mobile/src/player/offlineWave.ts new file mode 100644 index 0000000..1e65afe --- /dev/null +++ b/apps/mobile/src/player/offlineWave.ts @@ -0,0 +1,10 @@ +import type { Track } from '@pipu/core'; + +/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */ +export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] { + return tracks + .filter((t) => t.reaction !== 'dislike') + .map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) })) + .sort((a, b) => a.key - b.key) + .map((x) => x.t); +} diff --git a/apps/mobile/test/screens.test.ts b/apps/mobile/test/screens.test.ts new file mode 100644 index 0000000..7f449df --- /dev/null +++ b/apps/mobile/test/screens.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { releaseNeed } from '../src/lib/releaseNeed'; +import { offlineWave } from '../src/player/offlineWave'; +import type { Track } from '@pipu/core'; + +function track(id: string, reaction: Track['reaction']): Track { + return { + id, + title: id, + duration_ms: 1000, + artists: [], + album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] }, + genres: [], + disc_no: 1, + track_no: 1, + available: true, + stream_url: null, + reaction, + features: null, + }; +} + +describe('offline wave', () => { + it('drops dislikes and keeps likes ahead of the rest', () => { + const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')]; + const order = offlineWave(tracks, () => 0.5).map((t) => t.id); + expect(order).toEqual(['l', 'n']); + }); +}); + +describe('update dialog decision', () => { + it('marks an older install below the minimum as required', () => { + expect(releaseNeed(10, 12, 11)).toBe('required'); + expect(releaseNeed(11, 12, 11)).toBe('optional'); + expect(releaseNeed(12, 12, 11)).toBe('current'); + }); +}); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts new file mode 100644 index 0000000..43e56f4 --- /dev/null +++ b/apps/mobile/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/apps/web/e2e/scenarios.spec.ts b/apps/web/e2e/scenarios.spec.ts new file mode 100644 index 0000000..4b540d5 --- /dev/null +++ b/apps/web/e2e/scenarios.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); +}); + +test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByText(/Первый запуск/)).toBeVisible(); + await page.getByLabel('Логин').fill('admin'); + await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password'); + await page.getByRole('button', { name: 'Войти' }).click(); + await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible(); + await page.getByLabel('Новый логин').fill('owner'); + await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass'); + await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass'); + await page.getByRole('button', { name: 'Сохранить и продолжить' }).click(); + await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible(); +}); + +test('4. search suggest opens the artist and Enter opens search', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Логин').fill('alice'); + await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password'); + await page.getByRole('button', { name: 'Войти' }).click(); + const box = page.getByRole('combobox', { name: 'Поиск' }); + await box.fill('кино'); + await page.getByRole('option', { name: /Кино/ }).first().click(); + await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible(); + + await box.fill('кино'); + await box.press('Enter'); + await expect(page).toHaveURL(/\/search\?q=/); +}); + +test('settings expose crossfade and loudness normalization', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Логин').fill('alice'); + await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password'); + await page.getByRole('button', { name: 'Войти' }).click(); + await page.getByRole('button', { name: 'Меню профиля' }).click(); + await page.getByRole('menuitem', { name: 'Настройки' }).click(); + await expect(page.getByText('Выравнивание громкости')).toBeVisible(); + const slider = page.getByRole('slider', { name: /Кроссфейд/ }); + await slider.fill('4'); + await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible(); +}); +test('9. login page switches between Russian and English', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('button', { name: 'EN' }).click(); + await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); + await page.getByRole('button', { name: 'RU' }).click(); + await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 9186e8a..4686a8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,8 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@fontsource/jetbrains-mono": "^5.2.6", @@ -21,13 +22,16 @@ "@pipu/i18n": "workspace:*", "@tanstack/react-query": "^5.80.7", "@tanstack/react-router": "^1.120.20", + "@tanstack/react-virtual": "^3.14.21", "hash-wasm": "^4.12.0", + "music-metadata": "^12.2.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^15.5.3", "tus-js-client": "^4.3.1" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -35,6 +39,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", + "axe-core": "^4.14.0", "jsdom": "^26.1.0", "vite": "^6.3.5", "vitest": "^3.2.4" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..a6ce167 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] }, + webServer: { + command: 'pnpm exec vite --port 4173 --strictPort', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + env: { VITE_E2E: '1' }, + }, +}); diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index f5bf6d4..c226d62 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query'; import { createMemoryHistory } from '@tanstack/react-router'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import axe from 'axe-core'; import { I18nextProvider } from 'react-i18next'; import { afterEach, describe, expect, it } from 'vitest'; @@ -314,6 +315,22 @@ describe('my wave', () => { }); }); +describe('accessibility', () => { + it('home and login have no serious axe violations', async () => { + const alice = makeUser('alice', 'user'); + await renderApp([alice], '/', { signedInAs: alice }); + await screen.findByRole('button', { name: 'Бегаю' }); + const home = await axe.run(document.body, { resultTypes: ['violations'] }); + expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]); + + cleanup(); + await renderApp([], '/login'); + await screen.findByRole('heading', { name: 'Вход' }); + const login = await axe.run(document.body, { resultTypes: ['violations'] }); + expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]); + }); +}); + describe('safeRedirect', () => { it.each([ ['/settings', '/settings'], diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index b255163..aba05b3 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -7,16 +7,17 @@ import { redirect, type ParsedLocation, } from '@tanstack/react-router'; +import { lazy } from 'react'; -import { AuditPage } from '../pages/admin/AuditPage'; -import { LibraryPage } from '../pages/admin/LibraryPage'; -import { PatternsPage } from '../pages/admin/PatternsPage'; -import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage'; -import { UploadQueuePage } from '../pages/admin/UploadQueuePage'; -import { MyUploadsPage } from '../pages/uploads/MyUploadsPage'; -import { SuggestPage } from '../pages/uploads/SuggestPage'; -import { SourcesPage } from '../pages/admin/SourcesPage'; -import { UsersPage } from '../pages/admin/UsersPage'; +const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage }))); +const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage }))); +const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage }))); +const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage }))); +const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage }))); +const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage }))); +const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage }))); +const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage }))); +const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage }))); import { AlbumPage } from '../pages/AlbumPage'; import { ArtistPage } from '../pages/ArtistPage'; import { CollectionPage } from '../pages/CollectionPage'; diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index d584e16..566cf4d 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -1,6 +1,7 @@ import type { components } from '@pipu/api-client'; import { Link } from '@tanstack/react-router'; -import { Fragment } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Fragment, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDuration } from '../shared/lib/format'; @@ -129,82 +130,115 @@ export function TrackList({ const { t } = useTranslation(); const player = usePlayer(); const { track: current, playing } = usePlayerState(); + const listRef = useRef(null); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); + const multiDisc = Boolean(numbered && discs.size > 1); + const virtual = tracks.length > 30 && !multiDisc; + const virtualizer = useVirtualizer({ + count: virtual ? tracks.length : 0, + getScrollElement: () => listRef.current?.closest('main') ?? null, + estimateSize: () => 68, + overscan: 8, + }); + + const row = (tr: Track, i: number) => { + const isCurrent = current?.id === tr.id; + const play = () => tr.available && player.playList(tracks, i, context); + const liked = tr.reaction === 'like'; + const meta = rowMeta?.(tr, i); + return ( +
{ + if (!(e.target as HTMLElement).closest('a, button')) play(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && e.target === e.currentTarget) play(); + }} + > + {lead && {isCurrent ? : lead(tr, i)}} + + {numbered ? (tr.track_no ?? i + 1) : } + {tr.available && ( + + + + )} + + + {tr.title} + + + {meta && · {meta}} + + + {showAlbum && ( + + {tr.album.title} + + )} + + {disliked ? ( + + ) : ( + + )} + {formatDuration(tr.duration_ms)} + +
+ ); + }; + let lastDisc: number | null = null; return ( -
- {tracks.map((tr, i) => { - const disc = tr.disc_no ?? 1; - const header = numbered && discs.size > 1 && disc !== lastDisc; - lastDisc = disc; - const isCurrent = current?.id === tr.id; - const play = () => tr.available && player.playList(tracks, i, context); - const liked = tr.reaction === 'like'; - const meta = rowMeta?.(tr, i); - return ( - - {header &&
{t('catalog.disc', { n: disc })}
} +
+ {virtual + ? virtualizer.getVirtualItems().map((vi) => (
{ - if (!(e.target as HTMLElement).closest('a, button')) play(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && e.target === e.currentTarget) play(); - }} + key={vi.key} + data-index={vi.index} + ref={virtualizer.measureElement} + style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }} > - {lead && {isCurrent ? : lead(tr, i)}} - - {numbered ? (tr.track_no ?? i + 1) : } - {tr.available && ( - - - - )} - - - {tr.title} - - - {meta && · {meta}} - - - {showAlbum && ( - - {tr.album.title} - - )} - - {disliked ? ( - - ) : ( - - )} - {formatDuration(tr.duration_ms)} - + {row(tracks[vi.index]!, vi.index)}
- - ); - })} + )) + : tracks.map((tr, i) => { + const disc = tr.disc_no ?? 1; + const header = multiDisc && disc !== lastDisc; + lastDisc = disc; + return ( + + {header &&
{t('catalog.disc', { n: disc })}
} + {row(tr, i)} +
+ ); + })}
); } diff --git a/apps/web/src/features/uploads/uploader.ts b/apps/web/src/features/uploads/uploader.ts index 2a59e16..0d34704 100644 --- a/apps/web/src/features/uploads/uploader.ts +++ b/apps/web/src/features/uploads/uploader.ts @@ -17,15 +17,18 @@ export type Stage = | 'uploading' | 'done' | 'rejected' - | 'error'; + | 'error' + | 'resume'; export interface UploadItem { id: string; - file: File; + file: File | null; + size: number; relPath: string; stage: Stage; progress: number; // 0..1 текущего этапа (хэш или загрузка) sha256?: string; + tags?: { artist?: string; title?: string; album?: string; duration_ms?: number }; trackId?: string | null; possibleDuplicate?: boolean; code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … @@ -44,6 +47,17 @@ export interface UploaderState { const PARALLEL = 4; const CHUNK = 8 << 20; +const PENDING_KEY = 'pipu.uploads.pending'; + +interface PendingUpload { + id: string; + name: string; + size: number; + relPath: string; + sha256: string; + tusUrl: string; + uploadFileId: string; +} let seq = 0; @@ -56,7 +70,9 @@ export class Uploader { private uploads = new Map(); private refreshing: Promise | null = null; - constructor(private readonly session: AuthSession) {} + constructor(private readonly session: AuthSession) { + this.restore(); + } subscribe = (fn: () => void) => { this.listeners.add(fn); @@ -89,8 +105,8 @@ export class Uploader { continue; } // Тот же путь и размер уже в списке — не добавляем второй раз. - if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue; - fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 }); + if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue; + fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 }); } this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null }); this.hashQueue.push(...fresh.map((it) => it.id)); @@ -101,6 +117,7 @@ export class Uploader { if (this.state.busy) return; this.hashQueue = this.hashQueue.filter((x) => x !== id); this.set({ items: this.state.items.filter((it) => it.id !== id) }); + this.persist(); } /** Очистить список (после завершения или чтобы начать заново). */ @@ -108,6 +125,7 @@ export class Uploader { if (this.state.busy) return; this.hashQueue = []; this.set({ items: [], skipped: 0, batchId: null, error: null }); + this.persist(); } private hashWorker(): Worker { @@ -125,6 +143,7 @@ export class Uploader { if (!it) continue; try { const sha256 = await this.hash(it); + await this.readTags(it); this.patch(id, { sha256, stage: 'checking', progress: 1 }); } catch (err) { this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) }); @@ -137,13 +156,15 @@ export class Uploader { } private hash(it: UploadItem): Promise { + const file = it.file; + if (!file) return Promise.reject(new Error('no file')); const w = this.hashWorker(); return new Promise((resolve, reject) => { const onMessage = (e: MessageEvent) => { const m = e.data; if (m.id !== it.id) return; if (m.type === 'progress') { - this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) }); + this.patch(it.id, { progress: m.done / Math.max(it.size, 1) }); return; } w.removeEventListener('message', onMessage); @@ -151,7 +172,7 @@ export class Uploader { else reject(new Error(m.message)); }; w.addEventListener('message', onMessage); - w.postMessage({ id: it.id, file: it.file } satisfies HashRequest); + w.postMessage({ id: it.id, file } satisfies HashRequest); }); } @@ -162,7 +183,17 @@ export class Uploader { try { const res = unwrap( await this.session.client.POST('/api/v1/uploads/precheck', { - body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) }, + body: { + files: pending.map((it) => ({ + client_id: it.id, + size: it.size, + sha256: it.sha256!, + artist: it.tags?.artist, + title: it.tags?.title, + album: it.tags?.album, + duration_ms: it.tags?.duration_ms, + })), + }, }), ); for (const r of res.files) { @@ -192,9 +223,9 @@ export class Uploader { comment: comment.trim() || undefined, files: ready.map((it) => ({ client_id: it.id, - name: it.file.name, + name: it.file?.name ?? it.relPath, rel_path: it.relPath.includes('/') ? it.relPath : undefined, - size: it.file.size, + size: it.size, sha256: it.sha256!, })), }, @@ -209,6 +240,7 @@ export class Uploader { queue.push({ id: f.client_id, url: f.tus_url }); } } + this.persist(); await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); } catch (err) { this.set({ error: err }); @@ -239,9 +271,10 @@ export class Uploader { private upload(id: string, url: string): Promise { const it = this.item(id); - if (!it) return Promise.resolve(); + if (!it?.file) return Promise.resolve(); + const file = it.file; return new Promise((resolve) => { - const up = new tus.Upload(it.file, { + const up = new tus.Upload(file, { uploadUrl: url, chunkSize: CHUNK, retryDelays: [0, 1000, 3000, 5000, 10000, 20000], @@ -264,6 +297,7 @@ export class Uploader { onSuccess: () => { this.uploads.delete(id); this.patch(id, { stage: 'done', progress: 1 }); + this.persist(); resolve(); }, onError: (err) => { @@ -276,6 +310,100 @@ export class Uploader { up.start(); }); } + + private async readTags(it: UploadItem) { + if (!it.file) return; + try { + const { parseBlob } = await import('music-metadata'); + const meta = await parseBlob(it.file); + const duration = meta.format.duration; + this.patch(it.id, { + tags: { + artist: meta.common.artist, + title: meta.common.title, + album: meta.common.album, + duration_ms: duration ? Math.round(duration * 1000) : undefined, + }, + }); + } catch { + // Теги — подсказка для предпроверки; без них сервер прочитает их сам. + } + } + + /** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */ + private persist() { + const pending: PendingUpload[] = this.state.items + .filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } => + Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'), + ) + .map((it) => ({ + id: it.id, + name: it.file?.name ?? it.relPath, + size: it.size, + relPath: it.relPath, + sha256: it.sha256, + tusUrl: it.tusUrl, + uploadFileId: it.uploadFileId, + })); + try { + if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending)); + else localStorage.removeItem(PENDING_KEY); + } catch { + // Приватный режим — докачка после перезагрузки недоступна. + } + } + + private restore() { + try { + const raw = localStorage.getItem(PENDING_KEY); + if (!raw) return; + const rows = JSON.parse(raw) as PendingUpload[]; + if (!Array.isArray(rows) || !rows.length) return; + this.state = { + ...this.state, + items: rows.map((r) => ({ + id: r.id, + file: null, + size: r.size, + relPath: r.relPath, + stage: 'resume' as const, + progress: 0, + sha256: r.sha256, + tusUrl: r.tusUrl, + uploadFileId: r.uploadFileId, + })), + }; + } catch { + localStorage.removeItem(PENDING_KEY); + } + } + + /** Приложить заново те же файлы и продолжить tus с принятого смещения. */ + async continueWith(files: PickedFile[]) { + const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl); + if (!waiting.length) return; + const queue: { id: string; url: string }[] = []; + for (const picked of files) { + const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name))); + if (!it) continue; + this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 }); + const sha = await this.hash(this.item(it.id)!); + if (sha !== it.sha256) { + this.patch(it.id, { stage: 'error', message: 'sha256' }); + continue; + } + this.patch(it.id, { stage: 'uploading', progress: 0 }); + queue.push({ id: it.id, url: it.tusUrl! }); + } + if (!queue.length) return; + this.set({ busy: true }); + try { + await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + } finally { + this.set({ busy: false }); + this.persist(); + } + } } let instance: { session: AuthSession; uploader: Uploader } | null = null; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9911d14..ce22b37 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -48,6 +48,10 @@ function deviceName(): string { } applyTheme(undefined); +if (import.meta.env.VITE_E2E === '1') { + const { installE2EFetch } = await import('./test/e2eBridge'); + installE2EFetch(); +} const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages])); document.documentElement.lang = i18n.language; i18n.on('languageChanged', (l) => { diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index e3102f1..b8a28b0 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -143,7 +143,7 @@ function AppearanceSection() { {t('settings.search_collapse')} label={t('settings.search_collapse')} - value={user.ui_prefs.search_collapse === false ? 'off' : 'on'} + value={user.ui_prefs.search_collapse === true ? 'on' : 'off'} onChange={(v) => void setPrefs({ search_collapse: v === 'on' })} options={[ { value: 'on', label: t('settings.on') }, @@ -187,6 +187,30 @@ function AppearanceSection() { ]} />
+
+ {t('settings.normalize')} + + label={t('settings.normalize')} + value={user.ui_prefs.normalize === false ? 'off' : 'on'} + onChange={(v) => void setPrefs({ normalize: v === 'on' })} + options={[ + { value: 'on', label: t('settings.on') }, + { value: 'off', label: t('settings.off') }, + ]} + /> +
+
+ {t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })} + void setPrefs({ crossfade_s: Number(e.target.value) })} + /> +
{t('language.label')} diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx index 5fb9375..06fc69c 100644 --- a/apps/web/src/pages/admin/UploadQueuePage.tsx +++ b/apps/web/src/pages/admin/UploadQueuePage.tsx @@ -35,6 +35,7 @@ export function UploadQueuePage() { const [onlyPossible, setOnlyPossible] = useState(false); const [selected, setSelected] = useState>(new Set()); const [editing, setEditing] = useState(null); + const [batchEdit, setBatchEdit] = useState(null); const [rejecting, setRejecting] = useState(false); const list = useInfiniteQuery({ @@ -131,6 +132,7 @@ export function UploadQueuePage() { selected={selected} onToggle={toggle} onEdit={setEditing} + onEditBatch={setBatchEdit} onRetry={(id) => retry.mutate(id)} /> ))} @@ -158,6 +160,17 @@ export function UploadQueuePage() {
)} + {batchEdit && ( + setBatchEdit(null)} + onSaved={() => { + setBatchEdit(null); + void refresh(); + }} + /> + )} + {editing && ( ; onToggle: (ids: string[], on: boolean) => void; onEdit: (f: UploadFile) => void; + onEditBatch: (batchId: string) => void; onRetry: (id: string) => void; }) { const { t, i18n } = useTranslation('admin'); @@ -218,6 +233,12 @@ function BatchCard({ {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) => ( {f.possible_duplicate && {t('uploads.flag_possible')}} + {f.quality_upgrade && {t('uploads.flag_quality')}} {f.matched_album_id ? ( {t('uploads.flag_existing_album')} ) : ( @@ -302,6 +324,9 @@ function QueueRow({ )}
+ {f.stream_url && ( +

{t('uploads.set_note')}

@@ -528,5 +627,6 @@ function toForm(st: Settings) { 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, }; } diff --git a/apps/web/src/pages/uploads/MyUploadsPage.tsx b/apps/web/src/pages/uploads/MyUploadsPage.tsx index 40a4b47..853d569 100644 --- a/apps/web/src/pages/uploads/MyUploadsPage.tsx +++ b/apps/web/src/pages/uploads/MyUploadsPage.tsx @@ -1,7 +1,7 @@ import { unwrap } from '@pipu/core'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; @@ -31,6 +31,12 @@ export function MyUploadsPage() { const errorText = useErrorText(); const [filter, setFilter] = useState('all'); + useEffect(() => { + void session.client.POST('/api/v1/uploads/seen').then(() => { + void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] }); + }); + }, [session, queryClient]); + const list = useInfiniteQuery({ queryKey: ['my-uploads', filter], initialPageParam: undefined as string | undefined, diff --git a/apps/web/src/pages/uploads/SuggestPage.tsx b/apps/web/src/pages/uploads/SuggestPage.tsx index 17032dc..b3d982c 100644 --- a/apps/web/src/pages/uploads/SuggestPage.tsx +++ b/apps/web/src/pages/uploads/SuggestPage.tsx @@ -39,9 +39,10 @@ export function SuggestPage() { }, []); const ready = st.items.filter((it) => it.stage === 'ready'); - const readyBytes = ready.reduce((n, it) => n + it.file.size, 0); + const readyBytes = ready.reduce((n, it) => n + it.size, 0); const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking'); const finished = !st.busy && st.batchId !== null; + const resume = st.items.filter((it) => it.stage === 'resume').length; const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length; const uploaded = st.items.filter((it) => it.stage === 'done').length; const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null; @@ -96,7 +97,9 @@ export function SuggestPage() { multiple accept={ACCEPT} onChange={(e) => { - uploader.add(fromInput(e.target.files)); + const picked = fromInput(e.target.files); + if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked); + else uploader.add(picked); e.target.value = ''; }} /> @@ -106,12 +109,20 @@ export function SuggestPage() { type="file" multiple onChange={(e) => { - uploader.add(fromInput(e.target.files)); + const picked = fromInput(e.target.files); + if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked); + else uploader.add(picked); e.target.value = ''; }} />
+ {resume > 0 && ( +
+

{t('uploads.resume_hint', { count: resume })}

+ +
+ )} {st.skipped > 0 &&

{t('uploads.skipped', { count: st.skipped })}

} {st.items.length > 0 && ( @@ -215,7 +226,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean )} {note && {note.text}} - {formatBytes(item.file.size, i18n.language)} + {formatBytes(item.size, i18n.language)} {locked ? ( ) : ( diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 5594232..097faf7 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -1,5 +1,6 @@ import { EventBuffer, + normalizeGain, PlayAttempt, PlayQueue, unwrap, @@ -80,6 +81,9 @@ function deviceId(): string { */ export class Player { private readonly audio: HTMLAudioElement; + private readonly overlap: HTMLAudioElement; + private fading = false; + private ignoreEnded = false; private readonly queue = new PlayQueue(); private readonly events: EventBuffer; private attempt: PlayAttempt | null = null; @@ -102,6 +106,8 @@ export class Player { ) { this.audio = audio ?? new Audio(); this.audio.preload = 'auto'; + this.overlap = new Audio(); + this.overlap.preload = 'auto'; this.events = new EventBuffer({ send: async (events) => { unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); @@ -324,15 +330,17 @@ export class Player { } setVolume(v: number) { - this.audio.volume = Math.min(Math.max(v, 0), 1); + const volume = Math.min(Math.max(v, 0), 1); this.audio.muted = false; - this.update({ volume: this.audio.volume, muted: false }); + this.update({ volume, muted: false }); + this.applyLoudness(); this.saveLocal(); } toggleMute() { this.audio.muted = !this.audio.muted; this.update({ muted: this.audio.muted }); + this.applyLoudness(); } toggleShuffle() { @@ -382,6 +390,7 @@ export class Player { this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms }); this.audio.src = track.stream_url; if (startMs > 0) this.audio.currentTime = startMs / 1000; + this.applyLoudness(); this.attempt = new PlayAttempt(track, startMs); this.preloaded = null; this.events.push({ @@ -429,6 +438,107 @@ export class Player { } } + private prefs() { + const user = this.session.state.status === 'signed_in' ? this.session.state.user : null; + return user?.ui_prefs; + } + + private normalizeOn() { + return this.prefs()?.normalize !== false; + } + + private crossfadeSeconds() { + return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0)); + } + + /** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */ + private gain: GainNode | null = null; + private wired = false; + + private applyLoudness(mix = 1) { + const track = this.state.track; + const user = this.state.muted ? 0 : this.state.volume; + const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix; + if (!this.wired && typeof AudioContext !== 'undefined') { + try { + const ctx = new AudioContext(); + const src = ctx.createMediaElementSource(this.audio); + this.gain = ctx.createGain(); + src.connect(this.gain); + this.gain.connect(ctx.destination); + 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 секунд. */ + private maybeCrossfade(positionMs: number) { + const cf = this.crossfadeSeconds(); + if (this.fading || cf <= 0 || this.state.repeat === 'one') return; + const dur = this.audio.duration; + if (!Number.isFinite(dur)) return; + const left = dur * 1000 - positionMs; + if (left <= 0 || left > cf * 1000) return; + const next = this.state.queue[this.state.index + 1]; + if (!next?.stream_url || !next.available) return; + this.fading = true; + this.overlap.src = next.stream_url; + this.overlap.volume = 0; + void this.overlap.play().catch(() => { + this.fading = false; + }); + const started = performance.now(); + const step = () => { + if (!this.fading) return; + const p = Math.min(1, (performance.now() - started) / (cf * 1000)); + this.applyLoudness(1 - p); + this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); + if (p < 1) { + requestAnimationFrame(step); + return; + } + this.finishCrossfade(next); + }; + requestAnimationFrame(step); + } + + private finishCrossfade(next: Track) { + const at = this.overlap.currentTime; + this.ignoreEnded = true; + this.audio.pause(); + this.overlap.pause(); + this.finishAttempt('play_end'); + if (!this.queue.next(true)) { + this.fading = false; + this.ignoreEnded = false; + this.update({ playing: false, positionMs: 0 }); + return; + } + this.syncQueue(); + const src = next.stream_url; + if (!src) { + this.fading = false; + this.ignoreEnded = false; + this.update({ playing: false }); + return; + } + this.audio.src = src; + if (at > 0) this.audio.currentTime = at; + this.applyLoudness(); + this.attempt = new PlayAttempt(next, Math.round(at * 1000)); + this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms }); + this.updateMediaMetadata(next); + void this.audio.play().finally(() => { + this.fading = false; + this.ignoreEnded = false; + }); + } + private bindAudio() { const a = this.audio; a.addEventListener('playing', () => { @@ -451,10 +561,12 @@ export class Player { this.update({ positionMs: ms }); this.updatePositionState(); this.maybePreload(ms); + this.maybeCrossfade(ms); if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer(); this.saveLocal(); }); a.addEventListener('ended', () => { + if (this.ignoreEnded || this.fading) return; this.finishAttempt('play_end'); if (this.queue.next(true)) { this.syncQueue(); @@ -465,7 +577,6 @@ export class Player { } }); a.addEventListener('error', () => void this.onError()); - a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted })); } /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ diff --git a/apps/web/src/test/e2eBridge.ts b/apps/web/src/test/e2eBridge.ts new file mode 100644 index 0000000..f97f7a8 --- /dev/null +++ b/apps/web/src/test/e2eBridge.ts @@ -0,0 +1,15 @@ +import { fakeApi, makeUser } from './fakeApi'; + +/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ +export function installE2EFetch() { + const api = fakeApi([ + makeUser('admin', 'admin', { must_change_credentials: true }), + makeUser('alice', 'user'), + ]); + const orig = window.fetch.bind(window); + window.fetch = async (input, init) => { + const req = new Request(input, init); + if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req); + return orig(input, init); + }; +} diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 708b857..b9339e7 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -153,6 +153,16 @@ export function fakeApi(initial: FakeUser[]) { return tokens(user); case 'GET /api/v1/me/sessions': return json({ items: [] }); + case 'GET /api/v1/uploads/unseen': + return json({ count: 0 }); + case 'POST /api/v1/uploads/seen': + return new Response(null, { status: 204 }); + case 'GET /api/v1/onboarding': + return json({ required: false, artists: [], genres: [] }); + case 'GET /api/v1/me/likes': + case 'GET /api/v1/me/dislikes': + case 'GET /api/v1/me/history': + return json({ items: [], next_cursor: null, total: 0 }); case 'GET /api/v1/admin/users': { if (user.role === 'user') return problem(403, 'auth.forbidden'); const q = (url.searchParams.get('q') ?? '').toLowerCase(); diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 16bb11b..fcf884b 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -1,7 +1,7 @@ import { unwrap } from '@pipu/core'; import { useQuery } from '@tanstack/react-query'; import { Link, Outlet, useNavigate } from '@tanstack/react-router'; -import { useState, type ReactNode } from 'react'; +import { Suspense, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuthState, useSession, useUser } from '../app/session'; @@ -151,9 +151,10 @@ function Shell() { const staff = user.role !== 'user'; const island = user.ui_prefs.web_player === 'island'; const searchAt = user.ui_prefs.search_position ?? 'center'; - const search = ; + const search = ; const counts = useCollectionCounts(); const pending = usePendingUploads(user.role === 'admin'); + const unseen = useUnseenUploads(staff); return (
@@ -214,7 +215,7 @@ function Shell() { )} - + {user.role === 'admin' && ( <>
- + + +
@@ -261,3 +264,15 @@ function usePendingUploads(enabled: boolean) { }); return q.data; } + +/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */ +function useUnseenUploads(enabled: boolean) { + const session = useSession(); + const q = useQuery({ + queryKey: ['uploads-unseen'], + enabled, + queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count, + refetchInterval: 60_000, + }); + return q.data || undefined; +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9297f6d..fd75c74 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,5 +20,6 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['src/test/setup.ts'], + exclude: ['e2e/**', 'node_modules/**'], }, }); diff --git a/docs/data-model.md b/docs/data-model.md index df81858..3c2a4a6 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -136,6 +136,7 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз id uuid PRIMARY KEY, author_id uuid NOT NULL REFERENCES users(id), comment text, -- комментарий автора для админа + cover_path text, -- обложка пачки (JPEG/PNG/WebP), вшивается при импорте created_at timestamptz NOT NULL DEFAULT now() ); @@ -160,6 +161,7 @@ CREATE TABLE upload_files ( duplicate_of_track_id uuid REFERENCES tracks(id), duplicate_level text, -- sha256 | metadata | fingerprint possible_duplicate boolean NOT NULL DEFAULT false, + quality_upgrade boolean NOT NULL DEFAULT false, -- тот же трек, но лучше по качеству target_source_id uuid REFERENCES library_sources(id), target_rel_path text, status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения @@ -173,12 +175,18 @@ CREATE INDEX ON upload_files (status); CREATE INDEX ON upload_files (author_id, created_at DESC); CREATE INDEX ON upload_files (sha256); --- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса; --- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам. +-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса. +-- При старте api и каждые 10 минут сверяется с SUM(size_bytes) по «занимающим» статусам. CREATE TABLE upload_quota ( id boolean PRIMARY KEY DEFAULT true CHECK (id), used_bytes bigint NOT NULL DEFAULT 0 ); + +-- Когда автор последний раз открыл «Мои предложения» (бейдж новых решений). +CREATE TABLE upload_visits ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + seen_at timestamptz NOT NULL DEFAULT now() +); ``` ## Каталог diff --git a/docs/design.md b/docs/design.md index 57af943..01152eb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -157,10 +157,8 @@ interface WaveButtonProps { - Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить». - Офлайн-баннер при отсутствии сети. -## 5. Пробелы дизайна (дорабатываем при реализации) -- **Адаптивность веба**: прототип рассчитан на десктоп (≥ 1200 px). Для < 1024 px сайдбар сворачивается в иконки, - для < 768 px — нижняя навигация как в Android. Макетов нет — делаем по образцу Android-версии. -- **Пустые состояния и ошибки** есть не везде (например, хранилище недоступно, нет сети в вебе) — по образцу существующих - (иконка + короткий текст + действие). -- **Онбординг вкуса** (выбор жанров / исполнителей при первом входе) в прототипе отсутствует — макет нужен до M6. -- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках. +## 5. Адаптив и онбординг +- **Адаптивность веба**: при ширине < 1024 px сайдбар сворачивается в иконки, при < 768 px — нижняя навигация. +- **Онбординг вкуса** показывается на главной, пока `GET /onboarding` отвечает `required`. +- **Пустые состояния** — иконка, короткий текст и действие там, где список может быть пустым (поиск, коллекция, очередь). +- Строки интерфейса есть на русском и английском; длинные подписи проверяются на обоих языках. diff --git a/docs/playback.md b/docs/playback.md index 4bcd864..1c4098e 100644 --- a/docs/playback.md +++ b/docs/playback.md @@ -37,7 +37,8 @@ location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; - Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе. Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) — на сервере это прогревает транскод. -- Фоновая задача `pretranscode` для треков из выданных пачек волны (они почти наверняка будут сыграны). +- Фоновая подготовка: выдача пачки волны вызывает `playback.Prewarm` — для качества `high` транскод + строится сразу, не дожидаясь первого `Range` клиента. - Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час. - Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2). diff --git a/docs/testing.md b/docs/testing.md index 4cf9758..f9f896f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,13 +7,13 @@ | Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки | | Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования | | Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь | -| Компоненты | Web | `vitest` + Testing Library | Поиск (клавиатура, Enter / выбор), плеер, MoodPicker, формы админки | -| Компоненты | Mobile | `jest` + RNTL | Экраны входа, коллекции, диалог обновления | -| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер на тестовой библиотеке, API-хендлеры через `httptest` | +| Компоненты | Web | `vitest` + Testing Library + axe-core | Поиск, плеер, волна, формы админки, вход; серьёзные нарушения axe на главной и входе | +| Компоненты | Mobile | `vitest` (`apps/mobile/test`) | Офлайн-волна и решение диалога обновления | +| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер, API через `httptest`; `TestUserJourney` — сценарии 1–2 и скан | | Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных | -| Контракт | — | `schemathesis` против запущенного `api` | Ответы соответствуют `openapi.yaml`, нет 500 на фаззинге | -| E2E | Web | Playwright (Chromium + Firefox) | Сценарии ниже, на `deploy/compose.test.yaml` | -| E2E | Mobile | Maestro (эмулятор) | Вход, волна, фоновое воспроизведение, скачивание, офлайн — ночной прогон, не на каждый PR | +| Контракт | — | `tests/contract/schemathesis.sh` | Ответы соответствуют `openapi.yaml`; нужен запущенный `api` | +| E2E | Web | Playwright (`apps/web/e2e`, Chromium, `pnpm --filter @pipu/web test:e2e`) | Сценарии 1, 4 и 9 на фейковом API | +| E2E | Mobile | Maestro (`apps/mobile/.maestro`) | Вход, коллекция, проверка обновления. Ночной прогон (`PIPU_E2E_SERVER`, `PIPU_E2E_USER`, `PIPU_E2E_PASSWORD`), не на каждый PR | | Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест | ## Тестовая музыка @@ -27,6 +27,9 @@ ## E2E-сценарии (обязательные) +Сценарии 1, 4 и 9 гоняет Playwright. Сценарии входа, ролей, поиска, плеера, волны и языка — `apps/web/src/App.test.tsx`. +Сценарии источника и скана — `TestUserJourney` в `services/api` (нужен `TEST_DATABASE_URL`). + 1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная. 2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API). 3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске. diff --git a/docs/uploads.md b/docs/uploads.md index e2321b6..5f0bf2d 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -197,17 +197,19 @@ - Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`). - Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`): - «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути), - выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); предпроверка; `POST /uploads`; `tus-js-client` - с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон - модуля: переходы по приложению не прерывают загрузку; оборвавшиеся файлы продолжаются кнопкой - «Продолжить загрузку» (место уже зарезервировано). - - «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. + выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их + в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, + 401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку. + Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы, + клиент сверяет SHA-256 и продолжает с принятого смещения. + - «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. Бейдж — число решений + новее `upload_visits`; открытие страницы вызывает `POST /uploads/seen`. - «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка - по пачкам, правка метаданных и хранилища, массовое одобрение / отклонение с причиной, повтор импорта; - настройки очереди — внизу страницы. -- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`), - прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, чтение тегов на клиенте - (`music-metadata`) для предпроверки метаданных, продолжение загрузки после перезагрузки страницы. + по пачкам, правка метаданных и хранилища, правка исполнителя / альбома / года / обложки на всю пачку, + прослушивание staging по подписанной ссылке, флаг улучшения качества, массовое одобрение / отклонение с причиной, + повтор импорта. Настройки очереди — лимиты, хранилище и `allow_quality_upgrade`. +- Сверка квоты: `used_bytes` обновляется в транзакции смены статуса и дополнительно пересчитывается по + `SUM(size_bytes)` занимающих статусов при старте `api` и в цикле очистки (раз в 10 минут). ## 11. Очистка - `uploading` старше 24 ч → `expired`, файлы удаляются. diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index e231aa1..c770c8a 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -404,6 +404,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/uploads/unseen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Сколько решений по своим предложениям с прошлого визита (moderator, admin) */ + get: operations["unseenUploadDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/uploads/seen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Отметить свои предложения просмотренными */ + post: operations["markUploadsSeen"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/uploads/files/{id}": { parameters: { query?: never; @@ -484,6 +518,48 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/uploads/files/{id}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Прослушать файл из staging (подпись, как у стрима трека) + * @description Ссылку даёт `stream_url` файла очереди. Range поддерживается. + */ + get: operations["streamUpload"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/uploads/batches/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Правка метаданных и обложки на всю пачку (admin) + * @description Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте. + */ + patch: operations["updateUploadBatch"]; + trace?: never; + }; "/api/v1/admin/uploads/review": { parameters: { query?: never; @@ -1307,6 +1383,7 @@ export interface components { artist?: string; title?: string; duration_ms?: number; + album?: string; }[]; }; UploadPrecheckResult: { @@ -1399,6 +1476,8 @@ export interface components { /** @enum {string|null} */ duplicate_level: "sha256" | "metadata" | "fingerprint" | null; possible_duplicate: boolean; + /** @description Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade */ + quality_upgrade: boolean; /** Format: uuid */ target_source_id: string | null; /** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */ @@ -1412,6 +1491,8 @@ export interface components { updated_at: string; /** @description Только автору и пока файл в статусе uploading */ tus_url: string | null; + /** @description Подписанная ссылка прослушивания из staging; null, если файла уже нет */ + stream_url: string | null; }; UploadFilePage: { items: components["schemas"]["UploadFile"][]; @@ -1422,6 +1503,14 @@ export interface components { /** Format: uuid */ target_source_id?: string; }; + /** @description Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. */ + UploadBatchPatch: { + artist?: string; + album?: string; + year?: number | null; + /** @description JPEG, PNG или WebP, не больше 8 МБ после декодирования */ + cover_base64?: string | null; + }; UploadReviewRequest: { file_ids: string[]; /** @enum {string} */ @@ -1446,6 +1535,8 @@ export interface components { max_file_bytes: number; /** Format: uuid */ default_source_id: string | null; + /** @description Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли */ + allow_quality_upgrade: boolean; }; /** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */ UploadSettingsPatch: { @@ -1463,6 +1554,7 @@ export interface components { * @description null — без хранилища по умолчанию */ default_source_id: string | null; + allow_quality_upgrade?: boolean; }; HealthStatus: { /** @enum {string} */ @@ -1538,7 +1630,7 @@ export interface components { * @enum {string} */ search_position?: "center" | "left" | "right"; - /** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) */ + /** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) */ search_collapse?: boolean; /** @description Показывать на главной веба выбор занятия (по умолчанию true) */ home_activities?: boolean; @@ -2702,6 +2794,48 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + unseenUploadDecisions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Число */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + count: number; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + markUploadsSeen: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Отмечено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; withdrawUpload: { parameters: { query?: never; @@ -2803,6 +2937,61 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + streamUpload: { + parameters: { + query: { + exp: number; + sig: string; + }; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Аудио из staging */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "audio/*": string; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + updateUploadBatch: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadBatchPatch"]; + }; + }; + responses: { + /** @description Обновлённые файлы пачки */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["UploadFile"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; reviewUploads: { parameters: { query?: never; diff --git a/packages/core/src/gain.test.ts b/packages/core/src/gain.test.ts new file mode 100644 index 0000000..0c33e79 --- /dev/null +++ b/packages/core/src/gain.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeGain } from './gain'; + +describe('normalizeGain', () => { + it('lifts a quiet track toward -14 LUFS and clamps the extremes', () => { + expect(normalizeGain(-14, true)).toBeCloseTo(1); + expect(normalizeGain(-20, true)).toBeGreaterThan(1); + expect(normalizeGain(-40, true)).toBe(4); + expect(normalizeGain(0, true)).toBe(0.25); + expect(normalizeGain(-20, false)).toBe(1); + expect(normalizeGain(null, true)).toBe(1); + }); +}); diff --git a/packages/core/src/gain.ts b/packages/core/src/gain.ts new file mode 100644 index 0000000..7917a71 --- /dev/null +++ b/packages/core/src/gain.ts @@ -0,0 +1,9 @@ +/** Целевая громкость выравнивания (docs/playback.md). */ +export const TARGET_LUFS = -14; + +/** Множитель громкости к −14 LUFS. Выключено или нет замера — 1. Ограничен, чтобы тихий трек не орал. */ +export function normalizeGain(lufs: number | null | undefined, enabled: boolean): number { + if (!enabled || lufs == null || Number.isNaN(lufs)) return 1; + const gain = 10 ** ((TARGET_LUFS - lufs) / 20); + return Math.min(4, Math.max(0.25, gain)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cee478d..39ea9ec 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,5 +21,6 @@ export { type RepeatMode, type Track, } from './player'; -export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server'; +export { normalizeGain, TARGET_LUFS } from './gain'; +export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim'; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 70b1db5..6dccff5 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -203,6 +203,11 @@ "skipped_few": "{{count}} files skipped — not audio", "skipped_many": "{{count}} files skipped — not audio", "skipped_other": "{{count}} files skipped — not audio", + "resume_hint_one": "{{count}} file was not finished. Pick it again to resume.", + "resume_hint_few": "{{count}} files were not finished. Pick them again to resume.", + "resume_hint_many": "{{count}} files were not finished. Pick them again to resume.", + "resume_hint_other": "{{count}} files were not finished. Pick them again to resume.", + "resume_pick": "Pick files and resume", "comment": "Comment for the administrator", "comment_hint": "For example: “2011 remaster”, “replacing broken files”", "not_enough_space": "The queue is short of space: {{need}} needed, {{free}} free — some files will be refused.", @@ -275,6 +280,14 @@ "disc": "disc {{n}}", "edited": "edited", "flag_possible": "possible duplicate", + "flag_quality": "better quality", + "listen": "Listen to {{name}}", + "edit_batch": "Whole batch", + "batch_edit_title": "Edit batch", + "batch_edit_hint": "Empty fields stay as they are. The cover is written into the files on import.", + "batch_cover": "Batch cover", + "batch_cover_clear": "Remove cover", + "set_upgrade": "Accept the same track in better quality", "flag_existing_album": "existing album", "flag_new_album": "new album", "dup_level_sha256": "exact copy of a file", @@ -332,7 +345,8 @@ "uploading": "Uploading", "done": "Uploaded, processing", "rejected": "Not accepted", - "error": "Error" + "error": "Error", + "resume": "Waiting for the file to resume" } }, "server_settings": { diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 78f7a3e..0679e61 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -122,7 +122,9 @@ "search_right": "Right", "search_collapse": "Collapse search", "home_activities": "Activity picker on home", - "home_moods": "Mood picker on home" + "home_moods": "Mood picker on home", + "normalize": "Loudness normalization", + "crossfade": "Crossfade, {{seconds}} s" }, "form": { "show_password": "Show password", diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 2db6218..41f431f 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -203,6 +203,11 @@ "skipped_few": "Пропущено {{count}} файла — не аудио", "skipped_many": "Пропущено {{count}} файлов — не аудио", "skipped_other": "Пропущено {{count}} файла — не аудио", + "resume_hint_one": "Не докачан {{count}} файл. Выберите его снова — загрузка продолжится.", + "resume_hint_few": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.", + "resume_hint_many": "Не докачано {{count}} файлов. Выберите их снова — загрузка продолжится.", + "resume_hint_other": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.", + "resume_pick": "Выбрать файлы и продолжить", "comment": "Комментарий для администратора", "comment_hint": "Например: «ремастер 2011», «замена битых файлов»", "not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.", @@ -275,6 +280,14 @@ "disc": "диск {{n}}", "edited": "правлено", "flag_possible": "возможный дубль", + "flag_quality": "лучше по качеству", + "listen": "Слушать {{name}}", + "edit_batch": "На всю пачку", + "batch_edit_title": "Правка пачки", + "batch_edit_hint": "Пустые поля не меняются. Обложка запишется в файлы при импорте.", + "batch_cover": "Обложка пачки", + "batch_cover_clear": "Убрать обложку", + "set_upgrade": "Принимать тот же трек в лучшем качестве", "flag_existing_album": "в существующий альбом", "flag_new_album": "новый альбом", "dup_level_sha256": "точная копия файла", @@ -332,7 +345,8 @@ "uploading": "Загружается", "done": "Загружен, обрабатывается", "rejected": "Не принят", - "error": "Ошибка" + "error": "Ошибка", + "resume": "Ждёт файл, чтобы продолжить" } }, "server_settings": { diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 6d78283..2c2a3d8 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -122,7 +122,9 @@ "search_right": "Справа", "search_collapse": "Сворачивать поиск", "home_activities": "Выбор занятия на главной", - "home_moods": "Выбор настроения на главной" + "home_moods": "Выбор настроения на главной", + "normalize": "Выравнивание громкости", + "crossfade": "Кроссфейд, {{seconds}} с" }, "form": { "show_password": "Показать пароль", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9e26c..0e83d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@types/react': specifier: ~19.0.10 version: 19.0.14 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) apps/web: dependencies: @@ -147,9 +150,15 @@ importers: '@tanstack/react-router': specifier: ^1.120.20 version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': + specifier: ^3.14.21 + version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) hash-wasm: specifier: ^4.12.0 version: 4.12.0 + music-metadata: + specifier: ^12.2.3 + version: 12.2.3(supports-color@10.2.2) react: specifier: ^19.1.0 version: 19.3.1 @@ -163,6 +172,9 @@ importers: specifier: ^4.3.1 version: 4.3.1 devDependencies: + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 '@testing-library/dom': specifier: ^10.4.0 version: 10.4.2 @@ -184,15 +196,18 @@ importers: '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)) + axe-core: + specifier: ^4.14.0 + version: 4.14.0 jsdom: specifier: ^26.1.0 - version: 26.1.0(supports-color@10.2.2) + version: 26.1.0 vite: specifier: ^6.3.5 version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) packages/api-client: dependencies: @@ -212,7 +227,7 @@ importers: devDependencies: vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) packages/design-tokens: {} @@ -735,6 +750,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1091,6 +1109,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -1419,6 +1442,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.21': + resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.120.19': resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==} engines: {node: '>=12'} @@ -1426,6 +1455,9 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/virtual-core@3.19.6': + resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -1455,6 +1487,13 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tokenizer/inflate@0.5.1': + resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1662,6 +1701,10 @@ packages: await-lock@2.2.2: resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} + engines: {node: '>=4'} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1920,6 +1963,10 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2261,6 +2308,10 @@ packages: picomatch: optional: true + file-type@22.2.0: + resolution: {integrity: sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==} + engines: {node: '>=22'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2735,6 +2786,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + media-typer@2.0.0: + resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==} + engines: {node: '>=18'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -2861,6 +2916,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + music-metadata@12.2.3: + resolution: {integrity: sha512-YN0oQcaIhXyIW8lmmjErhegZhqpyO325oQu9ZjtOFXb/QfwgGjxpzIQUmEGHBpLlrCix42OmrlvUZFkfv+Eecw==} + engines: {node: '>=22'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3043,6 +3102,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -3518,6 +3587,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strtok3@10.6.2: + resolution: {integrity: sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==} + engines: {node: '>=18'} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -3630,6 +3703,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -3700,6 +3777,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.6.0: + resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==} + engines: {node: '>=18'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -3904,6 +3985,9 @@ packages: engines: {node: '>=8'} hasBin: true + win-guid@0.2.1: + resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + wonka@6.3.6: resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} @@ -4622,6 +4706,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@borewit/text-codec@0.2.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -5100,6 +5186,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.14)(react@19.0.0)': dependencies: react: 19.0.0 @@ -5454,6 +5544,12 @@ snapshots: react-dom: 19.3.1(react@19.3.1) use-sync-external-store: 1.7.0(react@19.3.1) + '@tanstack/react-virtual@3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + '@tanstack/router-core@1.120.19': dependencies: '@tanstack/history': 1.120.17 @@ -5462,6 +5558,8 @@ snapshots: '@tanstack/store@0.7.7': {} + '@tanstack/virtual-core@3.19.6': {} + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -5496,6 +5594,15 @@ snapshots: dependencies: '@testing-library/dom': 10.4.2 + '@tokenizer/inflate@0.5.1(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -5602,6 +5709,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.7(vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) + '@vitest/mocker@3.2.7(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))': dependencies: '@vitest/spy': 3.2.7 @@ -5715,6 +5830,8 @@ snapshots: await-lock@2.2.2: {} + axe-core@4.14.0: {} + babel-jest@29.7.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -6043,6 +6160,8 @@ snapshots: transitivePeerDependencies: - supports-color + content-type@3.1.1: {} + convert-source-map@2.0.0: {} core-js-compat@3.50.0: @@ -6373,6 +6492,15 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + file-type@22.2.0(supports-color@10.2.2): + dependencies: + '@tokenizer/inflate': 0.5.1(supports-color@10.2.2) + strtok3: 10.6.2 + token-types: 6.1.2 + uint8array-extras: 1.6.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -6491,7 +6619,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@10.2.2): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@10.2.2) @@ -6686,13 +6814,13 @@ snapshots: jsc-safe-url@0.2.4: {} - jsdom@26.1.0(supports-color@10.2.2): + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2(supports-color@10.2.2) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.28 @@ -6845,6 +6973,8 @@ snapshots: marky@1.3.0: {} + media-typer@2.0.0: {} + memoize-one@5.2.1: {} merge-stream@2.0.0: {} @@ -7069,6 +7199,21 @@ snapshots: ms@2.1.3: {} + music-metadata@12.2.3(supports-color@10.2.2): + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + content-type: 3.1.1 + debug: 4.4.3(supports-color@10.2.2) + file-type: 22.2.0(supports-color@10.2.2) + media-typer: 2.0.0 + strtok3: 10.6.2 + token-types: 6.1.2 + uint8array-extras: 1.6.0 + win-guid: 0.2.1 + transitivePeerDependencies: + - supports-color + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -7230,6 +7375,12 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.12 @@ -7736,6 +7887,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strtok3@10.6.2: + dependencies: + '@tokenizer/token': 0.3.0 + structured-headers@0.4.1: {} sucrase@3.35.0: @@ -7842,6 +7997,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -7899,6 +8060,8 @@ snapshots: typescript@5.8.3: {} + uint8array-extras@1.6.0: {} + undici-types@6.21.0: {} undici-types@8.9.0: {} @@ -7955,7 +8118,28 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): + vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.2.2) @@ -7976,6 +8160,20 @@ snapshots: - tsx - yaml + vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.10 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.5 + fsevents: 2.3.3 + lightningcss: 1.27.0 + terser: 5.51.2 + vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2): dependencies: esbuild: 0.25.12 @@ -7990,7 +8188,49 @@ snapshots: lightningcss: 1.27.0 terser: 5.51.2 - vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2): + vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) + vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.5 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -8013,11 +8253,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) - vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2) + vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.6.5 - jsdom: 26.1.0(supports-color@10.2.2) + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -8082,6 +8322,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + win-guid@0.2.1: {} + wonka@6.3.6: {} wrap-ansi@7.0.0: diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 9f8cb38..63faaad 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -164,6 +164,9 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis. uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log) upSvc.Run(ctx) + wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, + Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log) + wh.Warm = pb.Prewarm return server.NewRouter(server.Deps{ Log: log, System: sys, @@ -172,11 +175,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis. Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel), Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log), Playback: pb, - Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, - Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log), + Wave: wh, App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken, Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log), - Uploads: uploads.NewHandler(upSvc), + Uploads: uploads.NewHandler(upSvc, signer, cfg.XAccel), TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log), }), nil } diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index b8380b9..dec407d 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -924,7 +924,7 @@ type UiPrefs struct { MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"` Normalize *bool `json:"normalize,omitempty"` - // SearchCollapse Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) + // SearchCollapse Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) SearchCollapse *bool `json:"search_collapse,omitempty"` // SearchPosition Где в топбаре веба поиск (по умолчанию center) @@ -979,6 +979,16 @@ type UploadBatchCreated struct { } `json:"files"` } +// UploadBatchPatch Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. +type UploadBatchPatch struct { + Album *string `json:"album,omitempty"` + Artist *string `json:"artist,omitempty"` + + // CoverBase64 JPEG, PNG или WebP, не больше 8 МБ после декодирования + CoverBase64 *string `json:"cover_base64"` + Year *int `json:"year"` +} + // UploadBatchRequest defines model for UploadBatchRequest. type UploadBatchRequest struct { Comment *string `json:"comment,omitempty"` @@ -1018,13 +1028,19 @@ type UploadFile struct { MetadataEdited bool `json:"metadata_edited"` OriginalName string `json:"original_name"` PossibleDuplicate bool `json:"possible_duplicate"` - ReceivedBytes int64 `json:"received_bytes"` - ReviewedAt *time.Time `json:"reviewed_at"` - SampleRate *int `json:"sample_rate"` - SizeBytes int64 `json:"size_bytes"` - Status UploadStatus `json:"status"` - StatusReason *string `json:"status_reason"` - Tags map[string]string `json:"tags"` + + // QualityUpgrade Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade + QualityUpgrade bool `json:"quality_upgrade"` + ReceivedBytes int64 `json:"received_bytes"` + ReviewedAt *time.Time `json:"reviewed_at"` + SampleRate *int `json:"sample_rate"` + SizeBytes int64 `json:"size_bytes"` + Status UploadStatus `json:"status"` + StatusReason *string `json:"status_reason"` + + // StreamUrl Подписанная ссылка прослушивания из staging; null, если файла уже нет + StreamUrl *string `json:"stream_url"` + Tags map[string]string `json:"tags"` // TargetRelPath Путь в хранилище по write_template (null — не построился, причина в status_reason) TargetRelPath *string `json:"target_rel_path"` @@ -1065,6 +1081,7 @@ type UploadPatch struct { // UploadPrecheckRequest defines model for UploadPrecheckRequest. type UploadPrecheckRequest struct { Files []struct { + Album *string `json:"album,omitempty"` Artist *string `json:"artist,omitempty"` ClientId string `json:"client_id"` DurationMs *int `json:"duration_ms,omitempty"` @@ -1123,14 +1140,18 @@ type UploadReviewResult struct { // UploadSettings defines model for UploadSettings. type UploadSettings struct { - DefaultSourceId *openapi_types.UUID `json:"default_source_id"` - MaxFileBytes int64 `json:"max_file_bytes"` - PerUserLimitBytes *int64 `json:"per_user_limit_bytes"` - QueueLimitBytes int64 `json:"queue_limit_bytes"` + // AllowQualityUpgrade Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли + AllowQualityUpgrade bool `json:"allow_quality_upgrade"` + DefaultSourceId *openapi_types.UUID `json:"default_source_id"` + MaxFileBytes int64 `json:"max_file_bytes"` + PerUserLimitBytes *int64 `json:"per_user_limit_bytes"` + QueueLimitBytes int64 `json:"queue_limit_bytes"` } // UploadSettingsPatch Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). type UploadSettingsPatch struct { + AllowQualityUpgrade *bool `json:"allow_quality_upgrade,omitempty"` + // DefaultSourceId null — без хранилища по умолчанию DefaultSourceId *openapi_types.UUID `json:"default_source_id"` MaxFileBytes *int64 `json:"max_file_bytes,omitempty"` @@ -1316,6 +1337,12 @@ type ListUploadsParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } +// StreamUploadParams defines parameters for StreamUpload. +type StreamUploadParams struct { + Exp int64 `form:"exp" json:"exp"` + Sig string `form:"sig" json:"sig"` +} + // ListUsersParams defines parameters for ListUsers. type ListUsersParams struct { Q *string `form:"q,omitempty" json:"q,omitempty"` @@ -1427,6 +1454,9 @@ type ScanSourceJSONRequestBody = ScanRequest // CheckWriteTemplateJSONRequestBody defines body for CheckWriteTemplate for application/json ContentType. type CheckWriteTemplateJSONRequestBody = WriteTemplateCheckRequest +// UpdateUploadBatchJSONRequestBody defines body for UpdateUploadBatch for application/json ContentType. +type UpdateUploadBatchJSONRequestBody = UploadBatchPatch + // UpdateUploadJSONRequestBody defines body for UpdateUpload for application/json ContentType. type UpdateUploadJSONRequestBody = UploadPatch @@ -1549,12 +1579,18 @@ type ServerInterface interface { // Очередь на добавление (admin) // (GET /api/v1/admin/uploads) ListUploads(w http.ResponseWriter, r *http.Request, params ListUploadsParams) + // Правка метаданных и обложки на всю пачку (admin) + // (PATCH /api/v1/admin/uploads/batches/{id}) + UpdateUploadBatch(w http.ResponseWriter, r *http.Request, id EntityId) // Править метаданные и хранилище (admin) // (PATCH /api/v1/admin/uploads/files/{id}) UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) // Повторить импорт после import_failed (admin) // (POST /api/v1/admin/uploads/files/{id}/retry) RetryUploadImport(w http.ResponseWriter, r *http.Request, id EntityId) + // Прослушать файл из staging (подпись, как у стрима трека) + // (GET /api/v1/admin/uploads/files/{id}/stream) + StreamUpload(w http.ResponseWriter, r *http.Request, id EntityId, params StreamUploadParams) // Одобрить / отклонить файлы (admin) // (POST /api/v1/admin/uploads/review) ReviewUploads(w http.ResponseWriter, r *http.Request) @@ -1693,6 +1729,12 @@ type ServerInterface interface { // Квота очереди на добавление (moderator, admin) // (GET /api/v1/uploads/quota) GetUploadQuota(w http.ResponseWriter, r *http.Request) + // Отметить свои предложения просмотренными + // (POST /api/v1/uploads/seen) + MarkUploadsSeen(w http.ResponseWriter, r *http.Request) + // Сколько решений по своим предложениям с прошлого визита (moderator, admin) + // (GET /api/v1/uploads/unseen) + UnseenUploadDecisions(w http.ResponseWriter, r *http.Request) // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) GetWaveOptions(w http.ResponseWriter, r *http.Request) @@ -1846,6 +1888,12 @@ func (_ Unimplemented) ListUploads(w http.ResponseWriter, r *http.Request, param w.WriteHeader(http.StatusNotImplemented) } +// Правка метаданных и обложки на всю пачку (admin) +// (PATCH /api/v1/admin/uploads/batches/{id}) +func (_ Unimplemented) UpdateUploadBatch(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + // Править метаданные и хранилище (admin) // (PATCH /api/v1/admin/uploads/files/{id}) func (_ Unimplemented) UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) { @@ -1858,6 +1906,12 @@ func (_ Unimplemented) RetryUploadImport(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusNotImplemented) } +// Прослушать файл из staging (подпись, как у стрима трека) +// (GET /api/v1/admin/uploads/files/{id}/stream) +func (_ Unimplemented) StreamUpload(w http.ResponseWriter, r *http.Request, id EntityId, params StreamUploadParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Одобрить / отклонить файлы (admin) // (POST /api/v1/admin/uploads/review) func (_ Unimplemented) ReviewUploads(w http.ResponseWriter, r *http.Request) { @@ -2134,6 +2188,18 @@ func (_ Unimplemented) GetUploadQuota(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Отметить свои предложения просмотренными +// (POST /api/v1/uploads/seen) +func (_ Unimplemented) MarkUploadsSeen(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Сколько решений по своим предложениям с прошлого визита (moderator, admin) +// (GET /api/v1/uploads/unseen) +func (_ Unimplemented) UnseenUploadDecisions(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) func (_ Unimplemented) GetWaveOptions(w http.ResponseWriter, r *http.Request) { @@ -2823,6 +2889,37 @@ func (siw *ServerInterfaceWrapper) ListUploads(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } +// UpdateUploadBatch operation middleware +func (siw *ServerInterfaceWrapper) UpdateUploadBatch(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateUploadBatch(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // UpdateUpload operation middleware func (siw *ServerInterfaceWrapper) UpdateUpload(w http.ResponseWriter, r *http.Request) { @@ -2885,6 +2982,64 @@ func (siw *ServerInterfaceWrapper) RetryUploadImport(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// StreamUpload operation middleware +func (siw *ServerInterfaceWrapper) StreamUpload(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params StreamUploadParams + + // ------------- Required query parameter "exp" ------------- + + if paramValue := r.URL.Query().Get("exp"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "exp"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "exp", r.URL.Query(), ¶ms.Exp) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "exp", Err: err}) + return + } + + // ------------- Required query parameter "sig" ------------- + + if paramValue := r.URL.Query().Get("sig"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "sig"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "sig", r.URL.Query(), ¶ms.Sig) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sig", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StreamUpload(w, r, id, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ReviewUploads operation middleware func (siw *ServerInterfaceWrapper) ReviewUploads(w http.ResponseWriter, r *http.Request) { @@ -4260,6 +4415,46 @@ func (siw *ServerInterfaceWrapper) GetUploadQuota(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// MarkUploadsSeen operation middleware +func (siw *ServerInterfaceWrapper) MarkUploadsSeen(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.MarkUploadsSeen(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UnseenUploadDecisions operation middleware +func (siw *ServerInterfaceWrapper) UnseenUploadDecisions(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UnseenUploadDecisions(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetWaveOptions operation middleware func (siw *ServerInterfaceWrapper) GetWaveOptions(w http.ResponseWriter, r *http.Request) { @@ -4597,12 +4792,18 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/admin/uploads", wrapper.ListUploads) }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/admin/uploads/batches/{id}", wrapper.UpdateUploadBatch) + }) r.Group(func(r chi.Router) { r.Patch(options.BaseURL+"/api/v1/admin/uploads/files/{id}", wrapper.UpdateUpload) }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/uploads/files/{id}/retry", wrapper.RetryUploadImport) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/uploads/files/{id}/stream", wrapper.StreamUpload) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/uploads/review", wrapper.ReviewUploads) }) @@ -4741,6 +4942,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/uploads/quota", wrapper.GetUploadQuota) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/uploads/seen", wrapper.MarkUploadsSeen) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/uploads/unseen", wrapper.UnseenUploadDecisions) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/wave/options", wrapper.GetWaveOptions) }) @@ -5377,6 +5584,38 @@ func (response ListUploadsdefaultApplicationProblemPlusJSONResponse) VisitListUp return json.NewEncoder(w).Encode(response.Body) } +type UpdateUploadBatchRequestObject struct { + Id EntityId `json:"id"` + Body *UpdateUploadBatchJSONRequestBody +} + +type UpdateUploadBatchResponseObject interface { + VisitUpdateUploadBatchResponse(w http.ResponseWriter) error +} + +type UpdateUploadBatch200JSONResponse struct { + Items []UploadFile `json:"items"` +} + +func (response UpdateUploadBatch200JSONResponse) VisitUpdateUploadBatchResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUploadBatchdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UpdateUploadBatchdefaultApplicationProblemPlusJSONResponse) VisitUpdateUploadBatchResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type UpdateUploadRequestObject struct { Id EntityId `json:"id"` Body *UpdateUploadJSONRequestBody @@ -5436,6 +5675,47 @@ func (response RetryUploadImportdefaultApplicationProblemPlusJSONResponse) Visit return json.NewEncoder(w).Encode(response.Body) } +type StreamUploadRequestObject struct { + Id EntityId `json:"id"` + Params StreamUploadParams +} + +type StreamUploadResponseObject interface { + VisitStreamUploadResponse(w http.ResponseWriter) error +} + +type StreamUpload200AudioResponse struct { + Body io.Reader + ContentType string + ContentLength int64 +} + +func (response StreamUpload200AudioResponse) VisitStreamUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", response.ContentType) + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type StreamUploaddefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response StreamUploaddefaultApplicationProblemPlusJSONResponse) VisitStreamUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type ReviewUploadsRequestObject struct { Body *ReviewUploadsJSONRequestBody } @@ -6787,6 +7067,63 @@ func (response GetUploadQuotadefaultApplicationProblemPlusJSONResponse) VisitGet return json.NewEncoder(w).Encode(response.Body) } +type MarkUploadsSeenRequestObject struct { +} + +type MarkUploadsSeenResponseObject interface { + VisitMarkUploadsSeenResponse(w http.ResponseWriter) error +} + +type MarkUploadsSeen204Response struct { +} + +func (response MarkUploadsSeen204Response) VisitMarkUploadsSeenResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type MarkUploadsSeendefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response MarkUploadsSeendefaultApplicationProblemPlusJSONResponse) VisitMarkUploadsSeenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type UnseenUploadDecisionsRequestObject struct { +} + +type UnseenUploadDecisionsResponseObject interface { + VisitUnseenUploadDecisionsResponse(w http.ResponseWriter) error +} + +type UnseenUploadDecisions200JSONResponse struct { + Count int `json:"count"` +} + +func (response UnseenUploadDecisions200JSONResponse) VisitUnseenUploadDecisionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UnseenUploadDecisionsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UnseenUploadDecisionsdefaultApplicationProblemPlusJSONResponse) VisitUnseenUploadDecisionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetWaveOptionsRequestObject struct { } @@ -7038,12 +7375,18 @@ type StrictServerInterface interface { // Очередь на добавление (admin) // (GET /api/v1/admin/uploads) ListUploads(ctx context.Context, request ListUploadsRequestObject) (ListUploadsResponseObject, error) + // Правка метаданных и обложки на всю пачку (admin) + // (PATCH /api/v1/admin/uploads/batches/{id}) + UpdateUploadBatch(ctx context.Context, request UpdateUploadBatchRequestObject) (UpdateUploadBatchResponseObject, error) // Править метаданные и хранилище (admin) // (PATCH /api/v1/admin/uploads/files/{id}) UpdateUpload(ctx context.Context, request UpdateUploadRequestObject) (UpdateUploadResponseObject, error) // Повторить импорт после import_failed (admin) // (POST /api/v1/admin/uploads/files/{id}/retry) RetryUploadImport(ctx context.Context, request RetryUploadImportRequestObject) (RetryUploadImportResponseObject, error) + // Прослушать файл из staging (подпись, как у стрима трека) + // (GET /api/v1/admin/uploads/files/{id}/stream) + StreamUpload(ctx context.Context, request StreamUploadRequestObject) (StreamUploadResponseObject, error) // Одобрить / отклонить файлы (admin) // (POST /api/v1/admin/uploads/review) ReviewUploads(ctx context.Context, request ReviewUploadsRequestObject) (ReviewUploadsResponseObject, error) @@ -7182,6 +7525,12 @@ type StrictServerInterface interface { // Квота очереди на добавление (moderator, admin) // (GET /api/v1/uploads/quota) GetUploadQuota(ctx context.Context, request GetUploadQuotaRequestObject) (GetUploadQuotaResponseObject, error) + // Отметить свои предложения просмотренными + // (POST /api/v1/uploads/seen) + MarkUploadsSeen(ctx context.Context, request MarkUploadsSeenRequestObject) (MarkUploadsSeenResponseObject, error) + // Сколько решений по своим предложениям с прошлого визита (moderator, admin) + // (GET /api/v1/uploads/unseen) + UnseenUploadDecisions(ctx context.Context, request UnseenUploadDecisionsRequestObject) (UnseenUploadDecisionsResponseObject, error) // Занятия и настроения (ключи для i18n и иконки) // (GET /api/v1/wave/options) GetWaveOptions(ctx context.Context, request GetWaveOptionsRequestObject) (GetWaveOptionsResponseObject, error) @@ -7815,6 +8164,39 @@ func (sh *strictHandler) ListUploads(w http.ResponseWriter, r *http.Request, par } } +// UpdateUploadBatch operation middleware +func (sh *strictHandler) UpdateUploadBatch(w http.ResponseWriter, r *http.Request, id EntityId) { + var request UpdateUploadBatchRequestObject + + request.Id = id + + var body UpdateUploadBatchJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUploadBatch(ctx, request.(UpdateUploadBatchRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUploadBatch") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateUploadBatchResponseObject); ok { + if err := validResponse.VisitUpdateUploadBatchResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // UpdateUpload operation middleware func (sh *strictHandler) UpdateUpload(w http.ResponseWriter, r *http.Request, id EntityId) { var request UpdateUploadRequestObject @@ -7874,6 +8256,33 @@ func (sh *strictHandler) RetryUploadImport(w http.ResponseWriter, r *http.Reques } } +// StreamUpload operation middleware +func (sh *strictHandler) StreamUpload(w http.ResponseWriter, r *http.Request, id EntityId, params StreamUploadParams) { + var request StreamUploadRequestObject + + request.Id = id + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StreamUpload(ctx, request.(StreamUploadRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StreamUpload") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StreamUploadResponseObject); ok { + if err := validResponse.VisitStreamUploadResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ReviewUploads operation middleware func (sh *strictHandler) ReviewUploads(w http.ResponseWriter, r *http.Request) { var request ReviewUploadsRequestObject @@ -9134,6 +9543,54 @@ func (sh *strictHandler) GetUploadQuota(w http.ResponseWriter, r *http.Request) } } +// MarkUploadsSeen operation middleware +func (sh *strictHandler) MarkUploadsSeen(w http.ResponseWriter, r *http.Request) { + var request MarkUploadsSeenRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.MarkUploadsSeen(ctx, request.(MarkUploadsSeenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "MarkUploadsSeen") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(MarkUploadsSeenResponseObject); ok { + if err := validResponse.VisitMarkUploadsSeenResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UnseenUploadDecisions operation middleware +func (sh *strictHandler) UnseenUploadDecisions(w http.ResponseWriter, r *http.Request) { + var request UnseenUploadDecisionsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UnseenUploadDecisions(ctx, request.(UnseenUploadDecisionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UnseenUploadDecisions") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UnseenUploadDecisionsResponseObject); ok { + if err := validResponse.VisitUnseenUploadDecisionsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetWaveOptions operation middleware func (sh *strictHandler) GetWaveOptions(w http.ResponseWriter, r *http.Request) { var request GetWaveOptionsRequestObject @@ -9332,256 +9789,265 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/4K30PH7PwEA", + "H4sIAAAAAAAC/+y9/3LcxpUv/iqo2f1WkdkRSdmWk1C19S1ZtmPvWjEjytf3VqQ7BGeaJFYYYAxgKDEu", + "VZFibNmXirj2ZndTm9iON3drb1X+GVEcccRfqvITAK+QJ7nV53Q3uoFuAENiJHvr/mOLM5hGd5/Tp8/P", + "z/m40fa7Pd8jXhQ25j9u9OzA7pKIBPDX1X4Q+gH9V4eE7cDpRY7vNeYb8VfxMH6ebMan8UGyGQ+SB/FJ", + "shM/s+LDZDvZTLbi02TTikfxgbXkkbtRqw0DLVnwo2G8n+zE+8l28nk8jJ9ZyVZyn44Sn8Sj5NNkp9Fs", + "OPQtH/VJsNFoNjy7SxrzDRyj0WyE7TXStemsoo0e/SaMAsdbbdy712y85UVOtPFuh34Lg/TsaC0dw+k0", + "mo2AfNR3AtJpzEdBn8jjrfhB144a841+H57Mj/+e03UiMXhmhi58KQ/YISt2340a85fmmo2ufdfp9ruN", + "+Vfm6F+Oh39dFO9xvIiskgBetOj3gzaZ3EI+6Lm+3VmM7Kgfvu24EdGR+dvkfjxI7ifbyVayYyUP4iGQ", + "78CKD+JB/DzZhe8eNZoNcrfn+h3SmF+x3ZDoCRjCy5T9cSLSBU7764CsNOYbfzWbsuMsPhbOylNt3BOL", + "sYPA3qB/h9GGSz+gi6Z/fxCSYFIbd4/+OOz5Xkhg3guBv+ySLv1n2/ci4gFz2L2e67Rtuo2zPXzib/4h", + "pHv6sfSuojXzceGNGap8nXwWj+LH8WE8sKauv33V+ulrl348DVRlP6ejX+l0HY9uBRzrwO+RIHJw0u2A", + "2BHptOxIWXLHjsiFyOmS/LqbjY4T9lx7o4Xb+HHD67uuvUz3Hbcw9wOnU2E/mw0nbC27fvs26UgHetn3", + "XWJ79HvXDqOW6686XtF0S2fj+m3bJRqZ0Wx0+2HUaq/Z3ipptQPSIV7k2G6on478cM8Owzt+YJh44OP7", + "iqh8nT5zr9nohyTgG5s/qim3/hLZVzyeIQt7qVitsruGuZvX35T5JEuIW2KP/eV/IO2IrkJw3IK9SvJc", + "J856pUOfsq/mxEt3SgVezG4hvF4dRLscd7nfzS/DDiInjEqnD09dJyt0pLa/ToJW23f9QN2AHC9mF4q/", + "7AdupSPX6Qcgc1rK6OJWqXwmIycynJUosNu3qxPxBn1ct64NYhdRTr4Ic9yPs2NjyFuU2egmJ5W6MWIR", + "RppTquVl5gui4blJ9CK21rh1i/1u1w42flCnpp5D0Wr7fbz888fuBXO7PJ8ySr3HqHIuUS1TPkcOrfTV", + "Tsuz3Y3QCZmil2ch+v2vSNBaJ0E1ceh7RE+QFdtxFYVD+u6jPumbvgv6nkcH134Z+b0WCQKtvfRtPIiP", + "40GyS5XoATV44I/4VFLlki20jeKRFZ/Ew2Q73o8H8R59Ivkk2Up2rb9s/taKn8en8X6yRX8SH9D/Nq3k", + "QXI/PqVDj8CGup88LNeJMnRhy07XyPZPbFZTJYC8Xj01O4HvdK4Tl9ihRhvo+Hc8qtbzY5pXyxyvFfZ7", + "PT+IkByZLf2SGiPJVjzC/RrFT+OhFZ/Gj5NdsE5Okh34Mz6JT+M93BW6i42mhnaeH5Gw1dW95z9hb9ko", + "p/GT+NSaumYHt+kC0MQFsh3Rf8aDaZ3oCNfsVy69rl1l6PyKKOLH8aLXX9POcZ0EoYM2BLlrd3tg88zN", + "vD5zUfdO9nSrDVbZx2Wyhg+e+aG0M2yuYjVNlYRZgml5QlwCmZNNBUiRAK0oog0qdKmE1sldplDLU6sg", + "WcX9lRep51qCcYbaWfQ7TnSlHfnBmadxVoPEOJ23vEirFrSjHEvTwWbQ9NBNzeYLs133/ZXG/C9LLqd0", + "M+7dyprSVESCVKVyBKTykIppeqyP4pHFRPBR8kV8wh1cVPweJQ/jA5AHA/jJkUbe3mueycyuSJ+evUFP", + "HmxDp+PQ5djugrS1KPINV0BKm8gOVknU0r+19IJlv8bPxzbD4EoBwjQ5H6hDytNLl6zsq5HfajFBU8Z9", + "aTZoP1q74d8mnk4fardJGLYi+rVW6pG7PScgYcvxtK69zfg0PrTip3BxncQjCwe8QO+7+DAexiegW2zF", + "w/gw2Y5P4n1wzWq0IrISkHAtnUnmVf/OjswhvUf346Nk11pquw7xkM7zlo26wpJW7aaDChYjHjXJf9l4", + "g9gBkbdMFV1llL1GchRRdlN5rbKRbHwdrd7w/SiMArt3NXWiXCcf9YnuyvPIHZ3/SLoIyJ1WdSmsPN1U", + "R9fN9Sq4exbYI8ZZtvtBQMlUOtOCBzQzLZ4acMaNDMHvkGXQQIFRtHS/ClLhg5AExuVkfZhd++57xFuN", + "1hrzr7+mGXIyLjyJTvAC7SagiNO7byPS7fmBHWwo2545dN/AIR7EB8kOvafiYXIfbAhqPVAzwYKIy0Hj", + "jCdIcs9plkdHzU9St9C31okXvWFH7bX8Osk6j0pVEtcLrr0Bw4H1YN99F390aW6uxCRl7yme3nUSQiBH", + "J4V7kclg7PQxEEAMDrmA0Ddp7ZtvqQqSfEalZnxsMZNxM9mJh9bUzf7c3KvEenWOytOTeBg/mxY6yxZY", + "jyDU9+Ih/I6qLscWBNmGOF6j1Pkg1qUsQpqxbrvedojbeYvbwFkHWkevnK/QH5UfG3ysiePoXv4Osd1o", + "zeQ9CMXnXKT4tzVyJPNS9ivt65ww8oMNymVnVrOpNBpTQQQTpKLfVadxpa/kYxUsrg4tSt6nl6VGveOH", + "0ZtOoHd2dZxx/YsQUSxlWBZ3hOF1k3rPWaby0XBchCdJc15c0qrIYAFxW4bZNhshhJirDZU7jDgHeRDp", + "bc2G2TEkL7sO/lK28WUxGJuE0XXpLvdNMRmbuT1Lb1zVPXqPO3wNwwKFgAoFDxi+6jph6HirrYJHkOzV", + "qYTpDDh3kyhIg0v5992x10tVsQ/tdXKNRIHTDvPXGdurJqeFeB3fieyy1S1MVyxRjE1LyxD+quOZ9erU", + "+ilblKQOQybAutMmQoHNZeUMIMFmFB/Hw2TT+u7PV9cCv0usWetDx+v4d8LvjprWd39ecO4S1/rJd0eN", + "pqwDX5Q0JdnVUKD7n0nrlWLP8kbotvEa+X5nL3zP0wqcVi8gK+UJNs4CPDbJTAQxl/PlIeiY5H1v2beD", + "DgvM6OKOqpyaSGA3m6MTP46P4tP4KQvtQPwHowfxY/SHJJ/xDwbgHqEfo/Mx2UI3IwZzwMG4W8Uf9/Lc", + "5PIvx4wbZ3d1lXgBKSJYTdPXzLzK9NIh8qcz8wbx76Z0/bDlFfPx1TXfaRMTN7ecjro/pRSXreFKO156", + "K6QjvlpmXkuTLlz+gh2tLdhRRAJPoxOGLeJR5jeJRbJK7uZnPlem4OHvmvL4hrnReekNiB5+OYanQlpp", + "CXXyNgW+qmCSCwFZd8id8+rVbBiTstb3unbUXtM7PgxadPqb8ukbdadJ7nazEbIo1HlSd6sTTHisNLeS", + "F1GbA0V66XHkj2f95KCfMp1XThEJiR2010A8uy7hwZc1NNUbLAlC62ZdCfxuSX6ZehV+8MG7b67/2IpP", + "k/tWfAhx8mF8ktyPB5jPcBgfJY+SB1Y8ivch/PacPsqeOYlPwYM1oup4mZTz2+CvHk8z9FdWXEfJE5Gk", + "CnOWmJbb80OnMN8OL5eqCUYZ2oE2FUZ2EDG3TYt4YHHfdnpAQnIblOl+CPoWCfuwQte5DfqWx/7RcULx", + "Ef+3jrCUVVohCSH4fxafQPqItHCVKqZDsGy3b19FFjb50vKqt71M3AI97eOalmicMDVmifHkVo9QZzfg", + "3i1NLJkZfjjpUkWwlDHxfI+lQ2QlJQzRcrwO3rs633aP2FGry/y+wu26skK5wkO55Gp5MVzrr6y4hkNZ", + "dKhKd6bf65QZj6VjrPtuX1FBvX53WSP7pUMgE4TvvrqFQoY30uWreyheLHODsqJbZdz6rtfrR4UsOxaj", + "Ztiy3JWgsqW4Ruea9bBoYdAnx7HFr/9ecm/KeUIhudjULaQOjtRyk6QUml3W5fLJjtZaENAJzSksZgs8", + "nQ9Tr1Ki5jxip8wXlmxBws5zllI5BMs7+SweMEMdkh1EPhDkQ/BQqZkyCr8UeNoDETwsjdpcI5ENTHTb", + "6fVM1k5kr55r43JGkPDbqxuaTkOlGZuBWFiRv1+qkNIHBDMk+0M8gKRYkXF1SOkgp8qOeBaJc/EnXpPS", + "SnV7Ltn9aG3G8dZt1+nIriRtgkmHRLbj6iPoyW6yBdHUE6q18tcCvzyh7GRN0W/F5895zD0emBLFHmkT", + "RFM/vbHUawQaNKjIdNSTZCf5hL0EiihZRd4mVZjjAbVoq9hGUsBWG+cK7G5YlmSWo3gaatVoxEw3E9ZV", + "w172+9H8smt7txsVw7EFQeDrxG5HpHODR0nP7zo+R8BVqZ8yB1vlOdcREVP24NwRsWYj8iPbHcPMx+fL", + "g2YwT57+ye7XjNGiu175zwzKTCCNWrpL9Lm8NGRfGObcMYb52muEBbBMglk93kv+7SWet7G0YjvukjWF", + "koyl8qOJDAl5IoFuj8ufIeTmJZuQ3L5lSD3PyJM/xsP4INkG9zOU9lqsjHsPRgIxQyXY03g/PuVSBcen", + "0jU1xgsOvZRfQd+/Gtgd0qmcatHku6jffcg0NPqGxslEZAUKly269vgxlaVDVoAxSL5I7ltpHiTd9Lbv", + "33ZINQvxOovR8J2wO11IHaTKZGBjxmsmjTCl2GLb9hYCfzUgoS6S3OmYco2qq1/FYWHPCdfOaSGt+H3P", + "MMuVvusaYmAYfjWEpP1107p7gd8mYVi9Zkd6J7hXxr0Q/BZ7pf6FzCCrIDDTUhvYFWU6KiX4lipvl5fe", + "ZJyRvp7vWbqxgvBF6hplP+P54sTLaUpwcqjQYoABD614j0onK/l1PIifxUfJTtOiWhFVl1R5BoU1kNZG", + "hc8oPqBaXHwicu6HKQ3kQEt+4uDSxDS9whSMGmramtq4YuXKxnwgMQggqa6FiAn5Hf7XVLmDG2A/2Uy2", + "QQd9BomcUA92FA/ifSqxmlY8TLbgWqEaIkQUk0/AnKG7fZxsg4GTPGBmECqxJ6B4H4GQf1jllJ9Jj9Br", + "uZ+Cjn1IOeQ0fgy4IE9YRRssZCDbb/tMIX+GJ6Bqnk1Rtow59aRyHok2eFhLufQYqSxsi/MsVa6NLZJg", + "nQTveiu+5vD0nJa2/GxdW3u2zDPhW3K0VCs09tDG20P2tIC6x6yMkcHCUHFB/0+Z+vPkC8atqJgMk83k", + "MyoqRCyBhxcsiLc/jYeg46SGGQqnTBFlsq0RMljRxlLOW9l6upzVCnOOj1kwH21X9qOrfodwfeMCM1Ux", + "OQAYOdltgpXLFnpKlwcTpJyebEKFBkvhTlW+YbIJlQmMEhfn5n76U21lI4uWpzTrOb3+hW4/dNraWkVe", + "wdfC1I1Q+TG9s+hrQTmunikh8Y6pijPZVdYWD6ypRdL9bySYlpfZmJu5ODNXaiey2H5a1CgzsG6NBbTW", + "srP+AIV8kTVle6nmanbnQFk9TnbF6QGzv5oOwwo7tGnnw2QLCdLE8laFNZ9RKoETIh5QxVjyOmiPUCZh", + "rba8KzuMWv1wLM1NF66Sp6dmo+nQT/gL0/0r4IM6Kus5S529qB7zLk1lC2GrD9hKukv5GxBTXKaBGB2x", + "6Owm/nFgxacCE4qVrQ8ssBwfg3Q+Ep6zqZ+/vYgS+YQakKC9iIqUATVm4YvT+FhIeT4ccBjcBg+ntSw2", + "wTJLoHrYtmvAHhLOYU2dzj6oNc+Z/sPD3iL36yH923rv3TeuX7n+P1rvvL94o0nN9m0wxk/x5sNSj4dg", + "vVMdHH0ElbQ4UzqVtxK2yF0qKCudXPq4D8sKjcOFoGNUGo7uevUYqmI0awOoQEV6LwbrttvqOl4/0rlk", + "4n+M95ARYR9BRwY9YYSijyoMJ1KgIOOboYwNYHiIilYeNkjdJlWzp9NftbokDJm/sEKt7mqrFzh+4EQb", + "pXqovbrAH5V8tuUz5BfXncCJSCsi3Z7LYuSZXf4/adwFWfk+nncwBZNPhNmITvaO3w5nmaSa6Xb+6sfT", + "aqiGqYP7lDZjA18oaYwo+SVWVY6ByuTKwc5ssInfmjnBm9uspoSTp5I5I48yNxQcGPMVYPCWau4BDQab", + "IsCMEqQ0/qtKlHNKEK3E0J5wER+9VOVEvuSDIm3ipfL0RZlvzbSvRRVBReK8msiCvtSzXB35Ft1CGFlD", + "lQNtJJY/xnSUn7+9eCH5hKOJQgnl51T/CO5AXXvyAC/ZVOUY6JUKKdlUnQjgW6LgoTc1fzvAy1i6N4uy", + "yxEr4mRibperPyegPYFrPdmOn1PTcbppUfaUAH+yL9pigDQD7ex/4AdW3fE5025DKL/srm7UfMZrvdou", + "Z/W4ZDt+jD6P5OGMBTC7aA6jPwHCMMC18SFClsCvll575RULot2AcjfDZ9diMfClm16yZS1hOHcmIHbo", + "e0szFqj4aeiH+d7Q4yJFe9gNK5CVJK10wEPfgPjLmZ85NZBsQ6rLP88Xo1tLsJMX+FxnIeyzNHPTU4uS", + "DELQIF4Wc1GoHvE66Pzu+n0PHePo/YYoYzblW4rDZCvWzlrkW48RYVTTa1eUX4xGWqCFVdZ/SlSeGySM", + "jGX7/Y7jpwWOucvmUFbrBwAENIpPFbUU8k6eMW/0KYvcqsdJCHy0hkcqEgM9Pxfn5uamtTJK2sp80vJt", + "vaKGwmkcswB/geNlNuFPCH6E8QD0vx9AcBRkb4qOxKTx0gw4FvmpDqMlevfloV8yV/1lCyPdVHiDUj9t", + "Pg2mQhuINKdcItO2gDsy6dbeilDp9fIgCojd/UXfdtmlIaK7/chvNBt+4Kw6HiQ+rDmrazCjjgNp965/", + "RzukfLVIA0b2athacQLI1GdpV/QP7RAcZmNBqhqsAhdSfBwroncY0mxsjpxbGmFjAbFao2n2uu0w7snx", + "9D+DlvXQyipc9G5lZ9ua0mhxGSUMTnwz/QlehccMbmOoVyk7TthueX4VINAKUL4rxI76Ad/vKqIfiPU2", + "/1keDC3+Ry7mWIjiAAQXVVynwO23FR/RrUB5xvEO8ZZnCXAD7i9MPkVcxj0sY0x2wJ9HFaBNkIUnTBai", + "ohA/Acl5DJ5mnmGSu5g0ZWqlgYfKMAlpzlC1zUyzh/R3KJUVhtJQ7vGDpJ34BIQf1aCSrWQnPsKbA/hJ", + "XDvW1MVXrOTB9GXZ74RWRd58GFbxgZTD2VZj1CLEWhV0ORO1TEsB04MhvVo+xcp2SrSSzoBRNr0tnRJV", + "Ri33ugUL5PnT1BAkwepGpUddv9/xSBi23D7WXJf8IrN5dEbifdnRjCt8txPqNNOwxsz5HJELplOHq6Fa", + "GNzsaUjzqM03Ux5WEoA+U5jq/APmrygDVxPrZ5FhJSe12ovPCEEtCgaV7UnPMD/r4iwzmGqcGdsZHY04", + "5ECeQt1lhygF3Eqoxw/DFbtDWqFSBPF6s6SaY83vkhaVG+uOPhUzi5eGWgKI4ScQ69wDE/4Zyxakn+8l", + "O/FjbHSDeMO74ITYxduSqsfHoP0+YPfcI/Dp6HUDmF/X9zu1T+0E4aYxr4Ddt2eYYNdfdlzSgoLIQFZW", + "ob4/j4QiGa5U9rgM5liTgQdpW62277p2LyRaY2wPE0AgKXZPTuGgV+ghJEQdwh48jw+TbYBAO4K7EHbl", + "MXSsSX5NdzHZppfuWTaAzZOXzWjm+U/UFKSTAavnOaUEvaMlsqRTNk6gTbyI5TzwDYZP6IVAVujRC5zV", + "Nb0tEK2RrmLSdOzgNlSE0l80G+FGGJGuufhz3Q4cG4+eyOENlmGAj/oG1MU7ZFnDFMsgBJzQtT1D5m9e", + "IEDa4jVSGbwxF67eFrDqguEPeQb1Fji2WFAVzNf4GDA1yiAgCwBexkZXMS97LNTKF7Pwsr481XFo9Mum", + "xj8AKzKoS42GRr+tWjwtPDkmlBdM7TDUEhfAMP4brz66L0p76MbyaCQWzK30XbcpPuuHJGh91PcjO/NF", + "5Pst1w5WSfqRwFaUfu6l+Uls3VX0+fPUFUb9sHK7DJxmqwCFbjzPX0qZ3NjpxCQSSUstx2vJqticpTjD", + "3CrmTRGt0p85zFrGQq9d5ibH5OXd5FFyX0p+nrEQB2fZDsnrr6EZBzEcKbvmNIUMSrbpsAOIVo1m9Gmu", + "FYKFstJa+qg8wfyS/27hrZ81rYWf/4zXq3xIlheabM0poNHQ+okV/yFGlz93GOwz1NH9eMQ8pMzwr8Ka", + "YyiuRZQ0Q7H53S7TN8ugY8aUMuXRtXwY7pVLl0pQHHOp05psIYZUn2yzTHwRMoVr4nPQA5nKyBOPhjko", + "uLkJNbIoOP7c/6/0mdAd0Yy52nU89ufFEgFQdurfdlxdslw/WvNLIZHxEgeXJMoZibVK2Xysy27ZiVod", + "0kOGKLf9lp0oYLHK8ocZPWSWK5182++QdrUnz4Ldx6/IlkvWEfKD65miFUmXRHbHjmwsm1klQS9wvKjR", + "pBO61azSOY2/w19pnesqzThuyzecv6G+5FgGstRCm12HyQNIkSx0xcwnKWswHoKK892f48fggYTbKZvV", + "eWrFh1TfhCauoIDuJdvJI5AuUGAigdol2wA1OfZeioVwBLEzriT106erGPEoXPEqdFh8j860Fs6hlV3M", + "KJEQD0DnZOYjtkjHiUxqOo9KtYzx454fhs6yS1riDOhH+ggjX61+Dyo69dWVlFOeQokXuqdBQzgFczx5", + "IGAXgEgP+IZzIKin8T7WXdKL6jOpNELUMu3xAAxUfNiu699pZafV1ILDtYmzTjqt5Q2WYVKh3RIiXZyz", + "EBLhxFrVxS+9+8aaZrVwfbaFLousYypItXzc84Y0ODYBZYXP1PDPKD6wwshedbxVDHFIRBch94GVbGOP", + "rxMqkar1p1k9J74Ia0BToIB9k2pZhrihpaYMWVOZ1NXnLHhDVeNRfESlUtPKlIHHe5ZCsukx2vMUwnyP", + "YxoWtXVhqVjJJghNkfEjid493sMA2knHQ2bvYm5OHbhNFRJMJPtPVdGaXMeTsk9U0ZnXjJTDmhMy4org", + "6lGqh6liQVbncp1KEWVF0m6yQl93SWo0AJOGk9eutBdCXvpruCt/WrJyRhWqmfxlicKy3S/JnWK1vQ7k", + "DMkIeFlI8tKtb45eldv99MkxjP8xHh0z2FXJEhYhrtK3v+iQV3HEa5yAF9DVkP4sK4dVVcJq8r2S5x1m", + "FhDIwDTX/pf4Ps7ilMrbh2M5UEqTZOppvGkKwBZ4MsZ2YZzLa5HSTp/seD7nuN5AyAXpeHU+KG1K3t8x", + "Op6wPgcxHTJmGUPvoqpUU6oB56bACNQ9nV7P1yty8TRdi6Asnjne9XEzyd7PLgvhcA7p7JQGSjIewZE1", + "Fe8n2/FjumTuHwW17hNw546YXzi7CdPj25EFLCeg2FSQvyzpxvaZlzHfL/q+7q5yna4TjWXJdO276Pkf", + "50c9EkDju1bZ+8rvC6juHefd8N4xf5XZXHnWygwMK8u/NLdvZkpdLwbW7pC2E2bguOxeL/DXiYi/6CGh", + "MVxzvpSjAh8u5HyF2pj7N6qVxGpEAM4xX4WwBxDTo+S+ZK5UcHvrWx9B4hrfsfI91wvmvHZqQNSsiCTM", + "oBa17qKKzkN9arnOmIKsazOQ0BmTt5ingkSR461q1Qyd16eSMwqx04Qn6jjrhxoCBjn4oICdEFgn2YJS", + "GBwDqtlTJCOE4gHJL6OiKfgHgHN4TiP8ZYtGjHKPK9J1HeYz0swg5DLr1W1j08AJ5TxlCur+HoKxI059", + "A4DnLpQ+GDw9moQeBVbuEXc6AzhW/IR+KPuD9pKdTI3bbLa+OT6d1gWEDYeiIkMaumLz1Knsagem1Z7N", + "yV/K20X9Fsx8blpUfpMhmb0C6eXl1X14ivIXsz0l9IcmT9mCs5Crk5O9cQxWjv2BBXQt1lWkyXUCSlyn", + "2/ODCJ/Df0sft1Zsx82p4qwkUc3ouONEa53AvuOJZsv6bDMeYv0+tLX/0F4nb5iqmSHRdKOSY33Fdt1l", + "5tTQoHUeIoJ1fIKlVxji12b982rhAStyBgvlMTu64D1HF/znCK0HJWtHYIEdxqMmvRkfxY/BDEMwvnzk", + "Kx6ZkkP9an0Jxuq9MCH4NGkOzZRSbBFyV0BOFxPxjQ0rxiJ/xb27Z5jE24R0lrVFWH4/avtqPijdM5eg", + "B5o1Ebnt9FrEDtyNtHNIEfbuGA1NzNDzfGamjeU9HHUoa0NQxEaIVCtVFx3EA+vHohty7oJc3gAkf30Y", + "Y4hqIpro6eiAnLjH8cnoJ8cQ5hoK+15kBE6tX7RmrSvvLVqzFjtwT3myU/I5HXCapWOK+ieBKU5tFTyG", + "J1VRu2GT6HJEt8u8yQREbQF9S6KPUgGLczuNVea0I5QUvPLVujg3J3tE9sx3ofyCMCJeK1oL/P7qWvWJ", + "AYiAAd+Vnecq4I1sGP088lsmb4j0nmbKTUYOlomTt+rOS5qJ7TOsqmXEazBSIbPP6TDN8+y5aXd/rpW6", + "K5IkrHyKhPgsb8Omncr7vUgL+ee0te6K3yFe3whOzAmImWt2RALHdq3Fje6yD9iduY2/TTZUCMcUOLhY", + "6NIfNnEytwpXEBpvMWeMJr/SjmgkkihvOfdYuVb1Yqb8LabVLkI7rRdyY0OHrs6ZVdVsNzAeYMq0ciuF", + "VWeQVlprQMvVgRORGyxX4eoa0akWXSfECPMY5JSHvcZ+r566i5qWPVpggi8hfUBgq8CVepDqulQdSIFM", + "TrDjKrVjU3BpDiBMbfm/fP4f1k/n/j/FoQ9YPRB9kOBcjw0unsDve51WFDg9wwVld0kWi0eOLlXsrgDu", + "Nt5aQXqn/IKmTJpblahrdAefHZRK/LJ0BoIRztFLKG0jpElKOlKxdcGyAbI+SX4DEDrPkx2df4Nhaj8H", + "zAtW7i7Aywfx4WVriXXKWUozeRQOOpV6CJ0gNiAWvz/lql9jHATdggykPypcKiV+p6lT+pp3r0MM0Mw8", + "p+l5pkdSsp0D3tNkL40dxpISRsSsBGGLfMwKMy32V1dJqL+PZWY+M/OCSG/3AyfaWKRijZkWxA5IcKWP", + "tMG/3ubS/e8+vAE4NViZx75NqbEWRb3GPTqww3C385RA5zKASlLqXn9r8YZ1ZeFda8Hp9a1r/dBpz1jx", + "7xBDij5FtQtEfL8PYFE7EkaTAq/s2z3nQtvvkFXiTd/0sPVH2p4TkBf9HvHoY3S+OLHpGQuCvVB6mqZp", + "AkcATqPdc2a6HcRxYpHzRjpXOnUJmZkDOlNRj69qzDdenZmbeZVBn8AW0zFn1y/OQh+NWbvfcUBerRL4", + "H6UzpAG822nMN95zwugKPMH6F5GIBKExdJI+Mvue03UoV5U+eBWzd+7dgqBrz/dC5IRX5uZENz1M+7d7", + "6PhyfG/2H1gEC2/EUoQTugRIaQL2yIalERkdtv7TeGDFT5PtZJMJqoHk5jW9Rsw7jRkB9jf2O5hvxP+S", + "DogFPM9YkITemlNAiOmm3Lwh2WK28icIpg6pl7z1yS06ukpF11kO7GBj1vZsdyN06JQibH3Q80MNXa/T", + "r6+whxvn3Pls1xhw0Vbq2MEf1UgHDZlUGCnmw08eSPB9EEJSUJTroN6XDDMuLQCSXkFvBoFJmLsd8Sbb", + "VrCfGLKVDPwiKrngDYwhJLoz8hZRPu1CYzzI7+Gjb/G2JT/gAy0vpfq55k3vTuPDOvjif0vNWKqSXenq", + "ciZCr/lhdKHjlND6HT+M3nR0ZDZpJyakbFgaZf/dPE42fkVvuIcQjGjMN0SDDAgMNHjmqqBqVl+YJJew", + "TQAUFR2DqGjhtciKzJDZCpqBAjyeRtAYvIT0O9QyoGD+gkalPhPrpNUNWr75GeEiYpFnbU/6APMaCu3p", + "fZB8kdyHdBTYkZFAIU126iDVtwwe6zTZ5d7jxyAejgCc8D6DmcWdblqi3VjFTUeIi9mAiG6qpqsYHljk", + "PeQzW/6KuUMU5OlAOXCyhSBhyS7vIbHNClMH9TA11rtupZ4ABTNE6jmVzijZ0TAp65Sv3S7MJ5iVQItN", + "XJrJapkgm2bepOPTryRImGd1iZHcoNnmEPmt5ft2Cxv5apMy/sQK6h9yU4PSTITrMQ9nhOGbFITnlHe1", + "yrS44vdbsgs1jcyIFaxAB3gCrSkOcAFP432WksrLWTJ6FFw+e1BTiOXtkN4x02hmOIChfOSZAHwvb/id", + "jQnRH3Nd7qlqLEsI+y/Hgr8T7r2RwEg6O0/mzzvkVRQrMYvsmQnurgTprrc5sAzvND7MObbq2OT/GOti", + "b4orJNP5FNSEJRngXGqBoinjVZQQ1ZNx2VryVkI2Vq7FjBVGfmCvkgv2KvGimZtebgVDBb0aZQMrwttH", + "f53aTi8Lxi5Q3DP687xS8Ma0gVPR3w5lGepTqMvuQYr4EF0nKm8hSA7D4J+M4JDbRFQSGBdrfrWWnfXE", + "QiBiKrvr04DLkfzHUWCZsJj92OncQ/aHxAtN+kFqiadwMYp1lnxixY+hjnaUawWc/IZ1TcrPtyldd2ni", + "IWvopWj4+fvqTZitxG4K4V8rAIse1kWUr/Nw/znI6TGJ1DSqZ6aVzr0UFp+EmC7bmfE8K0xWdMBlYlLd", + "fpeJtgF08D5TywcWdAQTtTLCiixq7pGnt0nVegGS8qWoVi+ajbIqVZ1icZZF0AoNJ7HX+OQEt5a9w6hY", + "SX09qICG7DBshrdLGTXZrmO/sy+BEPcwdblP4PT2Iy2sFPodQR3DFNTnEN7bZcjeurYjjJ6i64glNR0B", + "j8ESleHZNiS5E7zQ11G9/mOcI/iLO8ZlvPatpI+KPthqJLYWh9K/xgP1eH92Fv6rdspneyyDfP7jc/Gr", + "1iW1gGO/SL5hr+T5Ey+Hg9gktEz0R0iLYX5a4bLFRlRSH5NaNGhmUXOMF1YwxVNzHvMGzik/8dISXd+g", + "s/Ma74VTA39pQnnQ8FZUiO0hpPBJPLzMA2RPULlhXZniPWvpZ2/dsLQu7aUZK/4T2pZ8RBSpcz+12JMz", + "0OiGpfpp5ORi2/Ymq+e0bU9i73t6T29tr0rbEhlCqVLDYeYsrksG8iCRaK6GLzs7I0Y8papuQXeDhC/M", + "WpH6J+nlyzA+SLYB+RPcHFamA9KoLtHChxxJzduVXiyjc+mhukZkk5Eimd5wAHqAeLFpV6hD65KUWD/Q", + "2faDZi778TFE5KUKQ+b2zmSdJbvxMWalSfmRedECmYFKctWERIw5H/EF36eatFcdz/8OowsviNNldYxV", + "Jmo69I0bli/i/RBz6CbD/V9mMyC1nC1lUe5B0GcIgP4n8YBlQiDSMAByPABX9xH2DZeUC0XbYWBqw3gP", + "du5zuQXbnrLHMze9+Kt8HsQUIs6pTYm56yJ3wEReJPox9qiw4s0+p1O7iV/yoldj3xMtb5Z0LmCW3pg/", + "lS/iREi5ldq0BKb/IXz1kFksz5TNrc9TK4d01SMi2m1C4qoe1vMZQ8lPtuNDcM1vjnt5lMV7qVn3gWjv", + "PN45kstz33bcCHLEP9YmqqSIdYKepSV5+pEAC6+OgbTQcblMGglV4oeVw5UBm6uYwVVzbt/XagSaNWFV", + "wXn1+lBBbJN9NbuMtQMibDEe877lRU60UeYW1iDqPmwqeMGAIPkEGy8oWlKq3hxK2e30B3uWWqdOh1DK", + "0Ges+OsUYB+b5GwxYmX6csIroVMGc/DdNzuaJYj5iQb0pXYEE9CMJoWZWBH1RXOOKK0wRfZI9oBJETLR", + "H6Eu3WsA+MIDDVRX8onFlGbBPyN28igTPRKTSbbPduwAY2uSh64UpZalS7Fg2458IJh7Q0gXpMR9sDBG", + "zCCHegPsTvVI0q1UgLexjtRET9NLTIvBg6LheJabWyc3M3tZh7+nB7E5J/NKOfznYGFz+j/u4Lsg1hsv", + "nVJKFn+qlw7rU3UZRpnUuomfHrm/inLNnZuEiLcr6bY5BYdheifbkB8DrsulFKV3SUbsVrWfGeu67a0S", + "7sDZh2+eqrevxs0JQwuhcFbOMmis5G6vkRUCWkXYDHKlHzh0VgsHPkNWeb/j+LM/UtlYzG/Z8Wx4f3bg", + "e+Y+vwry+tmZltXENeZ/eSvv0BCI7wOlJlF5NbbD4wjyoA0C6qYFzfwYJsdxPJDcYmMyuBR6MogX+n1q", + "tE3u6rn+MqNGGkzCYi8Xa9YQPwXxdkzpwVWwWuLdXwuxKeDOZAzHkcIz2vzsIuKHRKk50QRZNzFVEKT4", + "Icc1Y3lRo/gk2Z6xlj5asvB6ZGJX+d7ias0Qy31ZWGsbSwGwkhgUJW5s5AUcuAtgqjnpphMsHykmdXEb", + "P5NwguZ9zYpMw7v4/cAKKCkL0G2tbqprUd8ATqim+zw/eEmxBq+eNLpUv4Eeo6eICfYEk63gMPHsVzlV", + "1uKYSQeqK9T60Y8wfR0RlwfxwY9+dFno7M/ZAGCX7wGkMfQ55Xn10JXjBPxtci7BzE0v/gM+yxzWp8mm", + "lLyJrV7kdMYlel6XFPxnCKMCLhmv3D+lm2bOkKUEn5D4Tl8wlvC+WPMEOrBEU21YHrTwoZIwW1MVEiOh", + "iMbpsRJLa4BBQGtSZHXJqIKyVVNRky/qWe6flJRTIypo/maSDu94LuCQBCXm/B/5kZeih5ncYHGIoF4f", + "D+ZAnCAL76ojKGtRLsDj+AmzaliXQLyRD1LrHl90iK4A44bQ8/8Nt1EYLtsTnNghRK+wa/UTZa4MVxfH", + "A1ccqgFQcHxfAlc/EF7/w0wpssiiAOaace0wagEltEGVtP3uxJS+zlmkxgTuwcputmcmmj6cSJEONCRu", + "Wvwaa2r4shqiQCpN6CxIdKFnh+EdP+iM75CQj6D+4v2KlY1BsJGqeMdS2AvcgeJa1ty46CBT79zLDBUY", + "r+wtjjloRCE+ABfPMNkE2+qRyXy+TrcC9SC2GxNkthuk2/MDO9gQL9Mx3ZeFG1bPFZXpei2PfxZWCgh2", + "isJM13ASHJUHe8VMW6hiYwj3wrkKEXHIvdllZUaZ9A/MxPs15OwCD6at76lExeA4sF/8pGldffsCCPxD", + "VNPgDA5gFqPk/nTzpscrmQSuTFFLN8TVSgEy6S0hoDovW/kJpM8+Facjda7Rm+T3Ek7tPtwHArkWLTBW", + "F72JEfhTNl9+KjSSX5yL6xnSVtIxOIN9Vl/BS5Zn014jKkOU8K+73O+mOpUprf4KQ5Gb3LUDL9B6n6Q4", + "X7KlZDrVE8spfkG6e207sl1/9QwamuSvlre+15u1vU7gO53Zjn/Hc327M/sxQzoyU+NN9ugV/OmV3m2D", + "H4ABUzATniMoFfkYWep1Y77xP2/e7PzNzZsz0v+mLgRt9s/p//+vNY7D8azzda8zw1Y/07Pbt6HkMmiv", + "Oevk3B7LKwt/X7t38srC36vovlPxYfIbaDbEbGde1zlQ602HTaMDm/u3EQkDXFuHPEqpWAa9XubUSqzj", + "2jx51OS2Sg16aLd5TCUqROiEEKZz+ZkTEdti9enYvoir7SLGnlmxVIbKMX4tVMzTMnhecQgt6sExmyb9", + "jJJHNz1Mf0K3ADTllWDKrfgQ5zVjxV+J14ga7SH/muv5cHIRcIhSwaJEYwr+a5bd681g08DS3Ckq8XB7", + "38PdnaTkwxddJy6xQ2Ky1CW7iN6X6UIn4IaX35bsQmiW832ya7H5XtBQc9eaAje9RIbTeI9zFo/sHCjn", + "o5jPAbS0wvXEu+5Njkr4Br33W5OgUovpox14gheStNtMMSiGzYPnr+CDk9YNFnFfjCVQ0i1O9UddH+5d", + "a4qLN1Zdj1rlCcPfoUoyOh1UHWC6Zi2j/vm9IJ6I/N6FtMdCCV/c8Hs3eG+ECmEK6E2ixBjEjl+cA9hf", + "bHdyaa64v8tEgwSwICMLyqX5cNFyoEMW0IQe8Ttp/sweAwmuKVrwHO07QDLbkdpYMXtHx26sKtXUaRs9", + "+FRYf1IQ7Zgo+/WjtVnXX3U8ORab9Q/Ew/ixNbXEuhtSjpi37pDlpWk+621REh6QlYCEaxdS5caK96y2", + "7992iLXUc3r9VhAt3fSmlt6Jot77nrtx2VqkNyW5bC3aXbLoRORvF6PAaUeXrQU7WvtbeapL001+Pwqk", + "Dtz2obXEXt2K/NsEvItUpWFKInNriEChXOkueboGDE4YC8EURxIHjMx5OWWnCV7AKFXk+AyHaRNNpm56", + "S6/NXbTommZYeW6rHZAO8SLHdqEa7RuWI7+DRq3SFOk5sOMObjLILbmSn+W0/xSHj3y/1bW9jRY1Prq9", + "KNRqZO8BD0zG5wpjvyx3az9au0EZQl/G9ic4t8PkM05DRrD6tb4vGSfoI9YpGz6S9bV+lEU6Y+fVxypx", + "ffLEe/h9RccJ821mXZgQn2jmTq7FAChrxIb7MtmRT4mIbmBO4yGrLXym+GGrbtIF23XLNuqK61bbqy/P", + "6g4e1lUfnm6VyHaFywPLJRFXi+ECD5JPyjeJiUyz7L+uEeePYVVfyAg2OS6ZAvfl42khZ8FswSttYE0x", + "GQ7Y1eAY3MTyFPEW7uFkPseReBm/Z9MtZ/L3+LIIgT+XUvXo74e84Gg/+V/JrpKUL8fS0P7FBGKQ1MfJ", + "Q/pP2Uzf07suYZOYmNFrY7hDUmUG7lQ5sGn94phNt7CS+IUJ4K/YudnlMnCQ2fD6JfEfgegc7TKvsCA8", + "v/HgtP11OUpenpWZfMqsdWxUnSZpwkiYoylXW0BQQMq/Q5znZlqiD+f8MB7Mqynw4JGRkoqPraX1JYzf", + "po02TuLB9OWbnowsr5SrsNj1Uuj8ilAthOfWwp7xmXurjndXdjj89wtX2m3iXrhOOk5A2pHB53PVxx5E", + "NeeMro+V2FlTfqhxmF8RvZX16txck3ePef21Jv3z4itzc7fOZGI5XXuVzN4hy73zp5/+G8hfaE5Auaj+", + "A5ep88mw+odkeUFv4kiHjqzTFxfYKN+oMp/5QsHPlibZM/jZHYib7VpT8DxrSux0pjlooFx0kUbw9uGO", + "eBrvJzsajBw/jN7COU5GaMPgb7yMMon0zQUZqtD3m6O91mNwZ9pmAtGwm4rBmramqCCzLs3NyezUc+0N", + "bBsp8xP2YTS5O69NtJD3GjFuoYgQ17GF/y6051FRDkm6VV1SCP3L+vdYS32n1QvISrhkIQqnck6SLRW+", + "k2exHMejy5lSbKX+5TkbHR/J1BeaSpOukYmmCV0jL8lqNbCIKTuobs4x5gTtgq582NRC+Y4A6JXaBr/G", + "/iSKIgXMpZzA2WXfj8IosHsXJNdHgYz/dzmfDrNhl7r9MGq112xvleQcKHLSzz54iVgEjOoy2MNnkALG", + "nmImXWpxPolPZ256aXcz1nshZwJm0vEYVO1essP1JR64LNF0tSADJHqD79JVaZMmw/W6V31f/TaYz8+E", + "hyJJDgSIGtjdzQpbXx/EPctB46hIW/FhDtZcdkJmUtPSxEtRn8B0mpKjxHoEF0I3vsmf+UH3bblO7HZE", + "OhApqFpKUAd5f6tmOVXptdT2XZe0YU5Zeq05YeRjaaYpzfqqS+zgHfZcJf/U15Jn7rQuaIORBMSlpHU9", + "gmiarE9n07zSRme5ZDU1pibvkxkUON2LHzD7skVUL4KRtrwmVH41Nc+YM3hODi8VR+/9P1l0VhL+viYp", + "5HvLvh10HG8VtK6+hlCL9jp5P31sMnpH+oKra77D8ZTLlI3XDDWEMnzlaV1tfkRtPoNtSDsu5bJnwX/H", + "w4twtyuaAMM25CR7zhrgKBLxjr2ev+OV9Hl9F4d2PwiIF7X4o0sIULZL9RERUD5pWvEhBijT8PkDUI8U", + "dTodZErEMVkTfjUwypLP0hDWdKo3l6rKl1WlG0pac9EMOn49CvVVWJuSez+B0jTlJd9PDVqxjoR/mJqV", + "L0ph/la1MfUlARpdl3t0LoQR6yBrumIW2JOL0YSx2dQXmRCklVZhl3Ot8J7D6YS60GHyefIFc4VIpgS4", + "TreS3ZoyWpRaMNZ1JdPOTMyJCjWe8MISbg/gQhuBtau0YTK435r6G2ahr6HTBFCi5XeM0VXlRTOJ4jeD", + "4GWeLpO51Erp79phdAEBKu84XljFyzobUBXJ8b1wFhPbSqtLwewBjeo6+2nVRIYavc9sMF71MYgPgdMf", + "mY2W8wDsaPUuEuU3YRKxYBx+jAPxwtQupuWKFAKlzkhK0aym64YkDHmpmDGz8trGIn9skmjO+I5qLcNS", + "/anAIqslf1aUAfPxU8VtSpdgMl18TfMNLz3x18m6f5uIra9UbeN0qqEEGeAyb9WQG1UX1jkfVAhh/tJH", + "rNglv/XDgp1XbTptdsIS3zip3xsUBnIIgZxJM28MH1lyP0fmTPiEVdIeWVguOczYQVSNwWRLzMYGiGDF", + "z4GVLQ8QOxbAfg0ZBRnTdEIHVnqLtn4WNNcRXNv3Ifl8Qnf21/lCzbSQFEipyUNmFZlPYXqbdHq6HrW8", + "RipFah4U2qOsDauRxeiJ+1vblVoKIiG3lBbsmD4iY9YjlnMKcYSg6fGImn48AwawqJ+wClyowcSMFsq2", + "h5AFe5/jHVOJ9hymIMBFwQ+T7EAzHI/cjVpt8BTpMZ55S92KOEBmiSThAr0yN1cZGAge06aTNGxIW2QJ", + "JewvVk1A/8WrRlg9wS3tK39IzjakBeYghNUaINTU5VmHS4ROm0FJY2LWwVlCcdeni/2z1M6SW3zglk22", + "NW1zAXv9GXRJPolPmxlMISn7kSVzid4DVrwPpYVP6F0CMAewiiOIOx3SkfEPet6wTeZmsnPTm/ruz6tr", + "y53I++7I+sunX1rf/ZnVpkFl4ndH00YgI5RHMlgpj1lLa4TGLG0/CEg7Ip0WnICC08igz1/EoXy57Azg", + "6uy+PKgLWPfLZAfE5z544DA1RHghqcIpczfK7v341HpVf7UMqVL6qprSdRrvNa1LqX5+Gu+VnpJgnQQX", + "HG/FNx8ROvEDFQhX3AcD1mkAG83+GuEQ8GaYtzATmbIorxyRm0Vgjolcgfko084W0Bbg8WOGXS93epAT", + "KSFcYyhG5sVKLIMCdOrfwG0FXXQ0LVSS3WmD0rMI+/Uu3a6JMqh4i95I2QPfkeiycJopy64/j7D0lUKL", + "kYmS4b+NMCLdDP8BomkdGb0K7KowULNwmgVVAyOR3mtdfMVKHkxfttRU3WMsB1azdtF0WPoIip7OlrE7", + "bzHdm+lVHBE03xFE1IKzW5GpdPtUjL/f64e8py2Usg+0chx2Cdwb9ScD989lGH7fUWmbVTAoC0817P0v", + "+rZLT9jLgbmtXzKkCLqaU2eAhSh3XzKP5TLPh9QXEf2MeerCSTYdgDe82wlftM+6YkEuL8TkzVfTuAUm", + "Fwwup82vv5ByPoVX4PP6aqSkSWHXGR5iTbYtpwOVSfTylsuLlHogCSC7NC0949M2xaG4qJssmYpIVOPG", + "TrAaWWrkY0gB/S1ek8/TdHPoBqF0goLy16gf4jU8FfVD6+LM3MxcU0VkfOetK29as9bClRtX34GIOPqm", + "Rqjwvv6aFf8h/mIagVHwxk71vz1IlRmCEvqXzd/e9JbY1Gc+6pM+aa30XXfJmrXEx/2QBK2P+n5k43dN", + "aoptoxcGJ8yfFJ16lkTFj3jxwELnyB46bgpgV19kF5SXBL8qzYAhsRrj3FIhAcNdrcd9m0Ne5U1HGCQm", + "6lpMQ1VhMQVNT60pATXctKrBaevblKT+dXULZEZnMlHCDHqq9IFPtnmSymY8Sr6Ij5XuAfS39DhNAVjo", + "EDtRnzBF8BAY9P0e8a4svDudz9j/0InWOoF9R2onUiGj8T56FVBUN+Vtg4tlD3TgU3AW1pj2KN7KffL0", + "xH0hykhlUB60OtCVyUIDIwVtN6VgrUJytut4pCSYVXPTs//qrcF01K0Fa/wPCDmjGT/ZPcfx7wUk7Y1q", + "gkXN+A6zIgmBxmasxXeuXHjl0utggYpWQrLlyXsRMRWaIZ1Ndfx2yCc00+381Wuak7/A5vki2jnwd73U", + "hg7pJL4HLR2UXuCiQ+ogVUAg/LbPHc9PgCcO0EV1ZtYEVadIJcaN+gU8NnF64Gt0ZPg9x1TBsB3sAMsj", + "YWVoeNvUQYh/Y3pbtg1PUc/AMxMgJMQz26zX7ICfxkX6YOXLmG7Ig3pvWuyGNZJuWrOszDa0FxEFFUm0", + "cGf6Ht8bLW9+AF/j5rxJ2k4t2SBqP7+238cfCZytOZ3HSGnVh7+p1KrvP3kT3npU3Gy8lis5rKZURrTU", + "Uw1hXxnpPmPlTyyqe4BXyhkY/Y69Tmb9XlSY0/MzEn1or5P3e9Gkc3rk11TIEagreQRl1QhdxxnsTl4U", + "zaMKIsjvXPyJx/rksKB5WfY5bLacQaUXK4uRHcCGT6o7ub1O4B160JKLtb5IKrYvyguSOrKIfOkH58NR", + "SEn8FQyGVsh3f6Z6ZPIoxe7e/u6oOtVK07De8jqCdKV3wb++jGwogVmeX3WzliaduhLzCbPzVTrQ3Rff", + "Xr+QwTOgPLWxcybjP1W2qK4zq5FfLNnBoipRcl84J4RLJ22UCsoTYO7z6PCYR2PWo2SYRKvMn5O7kxaK", + "P2cs9PJY5pt6+QTqEliLeJUPAWCCt3MDSHkpQ5vqJAwYbUcpCk+xOvVssUZsN1r7VZEi8Q57ZJLFofAK", + "9LsUYIR8ikLRwphWcWTsXyDsJbIVnssDqOlLcmA8IHZno3A3ruMTky2u7GwU7AXDZThgEfCtNC2D+fA5", + "yDkPLF2ae/WFTe5rqKoRsXJN6qN24kNk4GF++kU0/icwK0+xgxOn9Gn8GEv7eOQzlbk8t2zHmqq4fdNa", + "RlHn9HFjmdgBCa70ozU6RSoQMbMHBWk/cBvzjVkQlGysj0UYHMe81xSfAO6a9HeXKN9Cow3pA9dZDuhu", + "SB/x4JT0EUs+kj4RgV/5h2kRgfQpCAt5Cr1e496te/83AAD//4mXndiwTQEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/playback/handler.go b/services/api/internal/playback/handler.go index 7d581c3..c378c33 100644 --- a/services/api/internal/playback/handler.go +++ b/services/api/internal/playback/handler.go @@ -196,6 +196,38 @@ func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error { return nil } +// Prewarm заранее транскодирует пачку волны (docs/playback.md, задача pretranscode). +func (h *Handler) Prewarm(ctx context.Context, ids []uuid.UUID) { + for _, id := range ids { + if ctx.Err() != nil { + return + } + f, err := h.store.StreamFile(ctx, id) + if err != nil || f.SourceStatus != "mounted" { + continue + } + codec, bitrate := "", 0 + if f.Codec != nil { + codec = *f.Codec + } + if f.Bitrate != nil { + bitrate = *f.Bitrate + } + target := Target(QualityHigh, f.Format, codec, bitrate) + if target == "" { + continue + } + root, err := h.rootFor(ctx, f.SourceID) + if err != nil { + continue + } + name := CacheName(f.FileID, f.Size, target) + if _, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target); err != nil { + h.log.WarnContext(ctx, "pretranscode failed", "track_id", id, "err", err) + } + } +} + // --- треки пачкой --- // GetTracksBatch реализует gen.StrictServerInterface. diff --git a/services/api/internal/uploads/batch.go b/services/api/internal/uploads/batch.go new file mode 100644 index 0000000..c91b97d --- /dev/null +++ b/services/api/internal/uploads/batch.go @@ -0,0 +1,141 @@ +package uploads + +import ( + "context" + "encoding/base64" + "os" + "path" + "path/filepath" + "strings" + + "github.com/google/uuid" + + "pipu/api/internal/auth" + "pipu/api/internal/platform/apperr" +) + +// BatchPatch — правка на всю пачку. Nil-поля не меняют файлы. +type BatchPatch struct { + Artist *string + Album *string + Year *int + SetYear bool + Cover []byte + ClearCover bool +} + +const maxCoverBytes = 8 << 20 + +// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении. +func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) { + if _, err := principal(ctx, auth.PermReviewUploads); err != nil { + return nil, err + } + files, err := s.store.PendingInBatch(ctx, id) + if err != nil { + return nil, err + } + for _, f := range files { + m := Meta{} + if f.Meta != nil { + m = *f.Meta + } + if in.Artist != nil { + m.Artist = *in.Artist + m.AlbumArtist = *in.Artist + } + if in.Album != nil { + m.Album = *in.Album + } + if in.SetYear { + m.Year = in.Year + } + if m.Title == "" { + m.Title = strings.TrimSuffix(f.OriginalName, path.Ext(f.OriginalName)) + } + if m.Artist == "" || m.Title == "" { + continue + } + if _, err := s.Update(ctx, f.ID, UpdateInput{Meta: &m}); err != nil { + return nil, err + } + } + if in.ClearCover || in.Cover != nil { + if err := s.writeCover(ctx, id, files, in); err != nil { + return nil, err + } + } + return s.store.PendingInBatch(ctx, id) +} + +func (s *Service) writeCover(ctx context.Context, id uuid.UUID, files []File, in BatchPatch) error { + prev := "" + if len(files) > 0 && files[0].CoverPath != nil { + prev = *files[0].CoverPath + } + if in.ClearCover || in.Cover == nil { + if prev != "" { + _ = os.Remove(s.stagingAbs(prev)) + } + return s.store.SetCover(ctx, id, nil) + } + if len(in.Cover) > maxCoverBytes { + return apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil) + } + ext, ok := coverExt(in.Cover) + if !ok { + return apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil) + } + rel := path.Join("covers", id.String()+ext) + abs := s.stagingAbs(rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return err + } + if err := os.WriteFile(abs, in.Cover, 0o644); err != nil { + return err + } + if prev != "" && prev != rel { + _ = os.Remove(s.stagingAbs(prev)) + } + return s.store.SetCover(ctx, id, &rel) +} + +func coverExt(b []byte) (string, bool) { + switch { + case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff: + return ".jpg", true + case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n": + return ".png", true + case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP": + return ".webp", true + default: + return "", false + } +} + +// DecodeCover декодирует обложку из base64. +func DecodeCover(s string) ([]byte, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover is not base64", nil) + } + return b, nil +} + +// Unseen — сколько решений автор ещё не открывал. +func (s *Service) Unseen(ctx context.Context) (int, error) { + p, err := principal(ctx, auth.PermSuggestTracks) + if err != nil { + return 0, err + } + return s.store.UnseenDecisions(ctx, p.UserID) +} + +// MarkSeen отмечает предложения просмотренными. +func (s *Service) MarkSeen(ctx context.Context) error { + p, err := principal(ctx, auth.PermSuggestTracks) + if err != nil { + return err + } + return s.store.MarkSeen(ctx, p.UserID, s.now()) +} diff --git a/services/api/internal/uploads/handler.go b/services/api/internal/uploads/handler.go index e351fb8..36d293d 100644 --- a/services/api/internal/uploads/handler.go +++ b/services/api/internal/uploads/handler.go @@ -10,17 +10,22 @@ import ( "pipu/api/internal/api/gen" "pipu/api/internal/auth" "pipu/api/internal/platform/apperr" + "pipu/api/internal/platform/sign" ) // Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface). type Handler struct { - svc *Service + svc *Service + signer *sign.Signer + xAccel bool } -// NewHandler создаёт обработчик. -func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } +// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging. +func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler { + return &Handler{svc: svc, signer: signer, xAccel: xAccel} +} -func fileDTO(ctx context.Context, f File) gen.UploadFile { +func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile { out := gen.UploadFile{ Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment, Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName}, @@ -28,7 +33,7 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile { SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate, SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags, MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID, - DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, + DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade, TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason, ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, } @@ -47,6 +52,12 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile { u := TusURL(f.ID) out.TusUrl = &u } + if h.signer != nil && f.StagingPath != "" && f.Status.KeepsStaging() { + if p, ok := auth.PrincipalFrom(ctx); ok && auth.Can(p.Role, auth.PermReviewUploads) { + u := h.streamURL(f.ID) + out.StreamUrl = &u + } + } return out } @@ -64,10 +75,10 @@ func metaFromDTO(m gen.UploadMeta) Meta { } } -func pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage { +func (h *Handler) pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage { out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))} for _, f := range files { - out.Items = append(out.Items, fileDTO(ctx, f)) + out.Items = append(out.Items, h.fileDTO(ctx, f)) } if next != "" { out.NextCursor = &next @@ -96,7 +107,7 @@ func deref[T any](p *T, def T) T { func settingsDTO(s Settings) gen.UploadSettings { return gen.UploadSettings{ QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes, - DefaultSourceId: s.DefaultSourceID, + DefaultSourceId: s.DefaultSourceID, AllowQualityUpgrade: s.AllowQualityUpgrade, } } @@ -174,7 +185,7 @@ func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsReques if err != nil { return nil, err } - return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil + return gen.ListMyUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil } // WithdrawUpload реализует gen.StrictServerInterface. @@ -195,7 +206,7 @@ func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObj if err != nil { return nil, err } - return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil + return gen.ListUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil } // UpdateUpload реализует gen.StrictServerInterface. @@ -209,7 +220,7 @@ func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestO if err != nil { return nil, err } - return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil + return gen.UpdateUpload200JSONResponse(h.fileDTO(ctx, f)), nil } // RetryUploadImport реализует gen.StrictServerInterface. @@ -218,7 +229,7 @@ func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImpo if err != nil { return nil, err } - return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil + return gen.RetryUploadImport200JSONResponse(h.fileDTO(ctx, f)), nil } // ReviewUploads реализует gen.StrictServerInterface. @@ -272,9 +283,59 @@ func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUpload QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes, PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil, DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil, + AllowQualityUpgrade: b.AllowQualityUpgrade, }) if err != nil { return nil, err } return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil } + +// UnseenUploadDecisions реализует gen.StrictServerInterface. +func (h *Handler) UnseenUploadDecisions(ctx context.Context, _ gen.UnseenUploadDecisionsRequestObject) (gen.UnseenUploadDecisionsResponseObject, error) { + n, err := h.svc.Unseen(ctx) + if err != nil { + return nil, err + } + return gen.UnseenUploadDecisions200JSONResponse{Count: n}, nil +} + +// MarkUploadsSeen реализует gen.StrictServerInterface. +func (h *Handler) MarkUploadsSeen(ctx context.Context, _ gen.MarkUploadsSeenRequestObject) (gen.MarkUploadsSeenResponseObject, error) { + if err := h.svc.MarkSeen(ctx); err != nil { + return nil, err + } + return gen.MarkUploadsSeen204Response{}, nil +} + +// UpdateUploadBatch реализует gen.StrictServerInterface. +func (h *Handler) UpdateUploadBatch(ctx context.Context, req gen.UpdateUploadBatchRequestObject) (gen.UpdateUploadBatchResponseObject, error) { + b := req.Body + in := BatchPatch{} + if b.Artist != nil { + in.Artist = b.Artist + } + if b.Album != nil { + in.Album = b.Album + } + if b.Year != nil { + in.SetYear = true + in.Year = b.Year + } + if b.CoverBase64 != nil { + if *b.CoverBase64 == "" { + in.ClearCover = true + } else { + raw, err := DecodeCover(*b.CoverBase64) + if err != nil { + return nil, err + } + in.Cover = raw + } + } + files, err := h.svc.UpdateBatch(ctx, req.Id, in) + if err != nil { + return nil, err + } + return gen.UpdateUploadBatch200JSONResponse{Items: h.pageDTO(ctx, files, "").Items}, nil +} diff --git a/services/api/internal/uploads/model.go b/services/api/internal/uploads/model.go index 3774c56..8c96827 100644 --- a/services/api/internal/uploads/model.go +++ b/services/api/internal/uploads/model.go @@ -138,22 +138,25 @@ type File struct { DuplicateOfTrackID *uuid.UUID DuplicateLevel *string PossibleDuplicate bool + QualityUpgrade bool TargetSourceID *uuid.UUID TargetRelPath *string StatusReason *string ReviewedBy *uuid.UUID ReviewedAt *time.Time ImportedFileID *uuid.UUID + CoverPath *string CreatedAt time.Time UpdatedAt time.Time } // Settings — лимиты очереди и хранилище по умолчанию (app_settings, меняет админ). type Settings struct { - QueueLimitBytes int64 `json:"queue_limit_bytes"` - PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен - MaxFileBytes int64 `json:"max_file_bytes"` - DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"` + QueueLimitBytes int64 `json:"queue_limit_bytes"` + PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен + MaxFileBytes int64 `json:"max_file_bytes"` + DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"` + AllowQualityUpgrade bool `json:"allow_quality_upgrade"` } // DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди). diff --git a/services/api/internal/uploads/quality.go b/services/api/internal/uploads/quality.go new file mode 100644 index 0000000..c92f8aa --- /dev/null +++ b/services/api/internal/uploads/quality.go @@ -0,0 +1,31 @@ +package uploads + +import "strings" + +// BetterQuality — загружаемый файл заметно лучше уже лежащего в библиотеке (docs/uploads.md#4). +// Lossless вместо lossy, либо тот же класс и битрейт выше хотя бы на 32 кбит/с и на 20%. +func BetterQuality(inCodec, inFormat string, inBitrate int, exCodec, exFormat string, exBitrate int) bool { + inLoss, exLoss := lossless(inCodec, inFormat), lossless(exCodec, exFormat) + if inLoss && !exLoss { + return true + } + if inLoss == exLoss && inBitrate > 0 && exBitrate > 0 && inBitrate >= exBitrate+32_000 && inBitrate*5 >= exBitrate*6 { + return true + } + return false +} + +func lossless(codec, format string) bool { + switch strings.ToLower(codec) { + case "flac", "alac", "wavpack", "ape", "tta": + return true + } + if strings.HasPrefix(strings.ToLower(codec), "pcm_") { + return true + } + switch strings.ToLower(format) { + case "flac", "wav", "aiff", "aif", "wv": + return true + } + return false +} diff --git a/services/api/internal/uploads/quality_test.go b/services/api/internal/uploads/quality_test.go new file mode 100644 index 0000000..049db19 --- /dev/null +++ b/services/api/internal/uploads/quality_test.go @@ -0,0 +1,21 @@ +package uploads + +import "testing" + +func TestBetterQuality(t *testing.T) { + if !BetterQuality("flac", "flac", 900000, "mp3", "mp3", 320000) { + t.Fatal("flac should replace mp3") + } + if BetterQuality("mp3", "mp3", 128000, "flac", "flac", 900000) { + t.Fatal("mp3 is not an upgrade over flac") + } + if !BetterQuality("mp3", "mp3", 320000, "mp3", "mp3", 128000) { + t.Fatal("much higher bitrate is an upgrade") + } + if BetterQuality("mp3", "mp3", 180000, "mp3", "mp3", 160000) { + t.Fatal("small bitrate step is not an upgrade") + } + if BetterQuality("flac", "flac", 0, "flac", "flac", 0) { + t.Fatal("same class without bitrate is not an upgrade") + } +} diff --git a/services/api/internal/uploads/service.go b/services/api/internal/uploads/service.go index fde9741..9aa42d2 100644 --- a/services/api/internal/uploads/service.go +++ b/services/api/internal/uploads/service.go @@ -435,12 +435,13 @@ func (s *Service) GetSettings(ctx context.Context) (Settings, error) { // SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить. type SettingsInput struct { - QueueLimitBytes *int64 - PerUserLimitBytes *int64 - MaxFileBytes *int64 - DefaultSourceID *uuid.UUID - ClearPerUser bool - ClearDefault bool + QueueLimitBytes *int64 + PerUserLimitBytes *int64 + MaxFileBytes *int64 + DefaultSourceID *uuid.UUID + AllowQualityUpgrade *bool + ClearPerUser bool + ClearDefault bool } // UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3). @@ -484,6 +485,9 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting } st.DefaultSourceID = in.DefaultSourceID } + if in.AllowQualityUpgrade != nil { + st.AllowQualityUpgrade = *in.AllowQualityUpgrade + } if err := s.store.SaveSettings(ctx, st); err != nil { return Settings{}, err } @@ -662,3 +666,21 @@ func (s *Service) removeStaging(f File) { // TusURL — адрес tus для файла. func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() } + +// Staged — файл очереди и абсолютный путь в staging. +type Staged struct { + File + Abs string +} + +// OpenStaging возвращает файл, который ещё лежит в staging. +func (s *Service) OpenStaging(ctx context.Context, id uuid.UUID) (Staged, error) { + f, err := s.store.File(ctx, id) + if err != nil { + return Staged{}, notFound(err) + } + if f.StagingPath == "" || !f.Status.KeepsStaging() { + return Staged{}, apperr.NotFound(CodeNotFound, "file is not in staging") + } + return Staged{File: f, Abs: s.stagingAbs(f.StagingPath)}, nil +} diff --git a/services/api/internal/uploads/store.go b/services/api/internal/uploads/store.go index 40fa75a..6657d62 100644 --- a/services/api/internal/uploads/store.go +++ b/services/api/internal/uploads/store.go @@ -230,8 +230,8 @@ func (st *Store) SetReceived(ctx context.Context, id uuid.UUID, offset int64) er const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.status, f.original_name, f.client_rel_path, f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate, f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id, - f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.target_source_id, f.target_rel_path, - f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, f.created_at, f.updated_at` + f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path, + f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at` const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id` @@ -243,8 +243,8 @@ func scanFile(row pgx.Row) (File, error) { err := row.Scan(&f.ID, &f.BatchID, &f.AuthorID, &f.AuthorName, &f.BatchComment, &f.Status, &f.OriginalName, &f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate, &f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID, - &f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.TargetSourceID, &f.TargetRelPath, - &f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CreatedAt, &f.UpdatedAt) + &f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath, + &f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return File{}, ErrNotFound } @@ -526,3 +526,91 @@ func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUI } return nil } + +// ReconcileQuota пересчитывает занятое место по статусам файлов. +func (st *Store) ReconcileQuota(ctx context.Context) error { + _, err := st.pool.Exec(ctx, ` + UPDATE upload_quota SET used_bytes = coalesce(( + SELECT sum(size_bytes) FROM upload_files WHERE status::text = ANY($1) + ), 0)`, occupyingStatuses) + if err != nil { + return fmt.Errorf("uploads: reconcile quota: %w", err) + } + return nil +} + +// BestAudio — лучший доступный файл трека (для сравнения качества). +func (st *Store) BestAudio(ctx context.Context, trackID uuid.UUID) (codec, format string, bitrate int, err error) { + err = st.pool.QueryRow(ctx, ` + SELECT coalesce(codec, ''), coalesce(format, ''), coalesce(bitrate, 0) + FROM media_files + WHERE track_id = $1 AND missing_since IS NULL + ORDER BY (codec IN ('flac', 'alac', 'wavpack', 'ape', 'tta') OR codec LIKE 'pcm_%') DESC, + bitrate DESC NULLS LAST + LIMIT 1`, trackID).Scan(&codec, &format, &bitrate) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", 0, nil + } + if err != nil { + return "", "", 0, fmt.Errorf("uploads: best audio: %w", err) + } + return codec, format, bitrate, nil +} + +// PendingInBatch — файлы пачки, которые ещё можно править. +func (st *Store) PendingInBatch(ctx context.Context, batchID uuid.UUID) ([]File, error) { + rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` + WHERE f.batch_id = $1 AND f.status IN ('pending_review', 'import_failed') + ORDER BY f.created_at, f.id`, batchID) + if err != nil { + return nil, fmt.Errorf("uploads: batch files: %w", err) + } + defer rows.Close() + var out []File + for rows.Next() { + f, err := scanFile(rows) + if err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +// SetCover записывает путь обложки пачки (nil — снять). +func (st *Store) SetCover(ctx context.Context, batchID uuid.UUID, path *string) error { + tag, err := st.pool.Exec(ctx, `UPDATE upload_batches SET cover_path = $2 WHERE id = $1`, batchID, path) + if err != nil { + return fmt.Errorf("uploads: set cover: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// UnseenDecisions — решения по файлам автора новее последнего визита. +func (st *Store) UnseenDecisions(ctx context.Context, userID uuid.UUID) (int, error) { + var n int + err := st.pool.QueryRow(ctx, ` + SELECT count(*) FROM upload_files f + LEFT JOIN upload_visits v ON v.user_id = f.author_id + WHERE f.author_id = $1 + AND f.status IN ('imported', 'rejected', 'duplicate', 'invalid', 'expired', 'import_failed') + AND f.updated_at > coalesce(v.seen_at, '-infinity')`, userID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("uploads: unseen: %w", err) + } + return n, nil +} + +// MarkSeen отмечает визит автора. +func (st *Store) MarkSeen(ctx context.Context, userID uuid.UUID, at time.Time) error { + _, err := st.pool.Exec(ctx, ` + INSERT INTO upload_visits (user_id, seen_at) VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE SET seen_at = EXCLUDED.seen_at`, userID, at) + if err != nil { + return fmt.Errorf("uploads: mark seen: %w", err) + } + return nil +} diff --git a/services/api/internal/uploads/stream.go b/services/api/internal/uploads/stream.go new file mode 100644 index 0000000..f0d0ef0 --- /dev/null +++ b/services/api/internal/uploads/stream.go @@ -0,0 +1,92 @@ +package uploads + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/google/uuid" + + "pipu/api/internal/api/gen" + "pipu/api/internal/platform/apperr" + "pipu/api/internal/platform/httpx" +) + +// uploadStream — отдача файла staging с Range (сам api или X-Accel-Redirect). +type uploadStream struct { + req *http.Request + file string + format string + accel string + xAccel bool +} + +func (s uploadStream) VisitStreamUploadResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", contentType(s.format)) + w.Header().Set("Cache-Control", "private, max-age=600") + if s.xAccel && s.accel != "" { + w.Header().Set("X-Accel-Redirect", s.accel) + w.WriteHeader(http.StatusOK) + return nil + } + f, err := os.Open(s.file) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return err + } + if s.req == nil { + http.ServeContent(w, &http.Request{}, "", info.ModTime(), f) + return nil + } + http.ServeContent(w, s.req, "", info.ModTime(), f) + return nil +} + +func (h *Handler) streamURL(id uuid.UUID) string { + exp := time.Now().Add(12 * time.Hour).Unix() + sig := h.signer.Sign("upload", id.String(), strconv.FormatInt(exp, 10)) + return "/api/v1/admin/uploads/files/" + id.String() + "/stream?exp=" + strconv.FormatInt(exp, 10) + "&sig=" + sig +} + +// StreamUpload реализует gen.StrictServerInterface. +func (h *Handler) StreamUpload(ctx context.Context, req gen.StreamUploadRequestObject) (gen.StreamUploadResponseObject, error) { + if h.signer == nil || !h.signer.Verify(req.Params.Sig, "upload", req.Id.String(), strconv.FormatInt(req.Params.Exp, 10)) || time.Now().Unix() > req.Params.Exp { + return nil, apperr.Forbidden("uploads.link_invalid", "link is invalid or expired") + } + f, err := h.svc.OpenStaging(ctx, req.Id) + if err != nil { + return nil, err + } + format := "" + if f.Format != nil { + format = *f.Format + } + return uploadStream{ + req: httpx.RequestFrom(ctx), file: f.Abs, format: format, xAccel: h.xAccel, + accel: "/_internal/uploads/" + filepath.ToSlash(f.StagingPath), + }, nil +} + +func contentType(format string) string { + switch format { + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "m4a": + return "audio/mp4" + case "ogg", "opus": + return "audio/ogg" + case "wav": + return "audio/wav" + default: + return "application/octet-stream" + } +} diff --git a/services/api/internal/uploads/worker.go b/services/api/internal/uploads/worker.go index 8806c78..314600e 100644 --- a/services/api/internal/uploads/worker.go +++ b/services/api/internal/uploads/worker.go @@ -41,7 +41,16 @@ func (s *Service) Run(ctx context.Context) { s.reconcile(ctx) go s.loop(ctx, s.wakeProcess, pollEvery, func(ctx context.Context) bool { return s.processNext(ctx) }) go s.loop(ctx, s.wakeImport, pollEvery, func(ctx context.Context) bool { return s.importNext(ctx) }) - go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool { s.expire(ctx); return false }) + go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool { + s.expire(ctx) + if err := s.store.ReconcileQuota(ctx); err != nil { + s.log.Error("uploads: reconcile quota", "err", err) + } + return false + }) + if err := s.store.ReconcileQuota(ctx); err != nil { + s.log.Error("uploads: reconcile quota", "err", err) + } wake(s.wakeProcess) wake(s.wakeImport) } @@ -98,6 +107,7 @@ type outcome struct { dupTrack *uuid.UUID dupLevel string possible bool + quality bool place placement } @@ -144,8 +154,12 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome { cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID) if err == nil { if c, ok := bestMatch(fp.Points, cands); ok { - o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint - return o + if s.keepAsUpgrade(ctx, &o, c.TrackID, probe) { + // лучшее качество — дальше как обычный файл на рассмотрение + } else { + o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint + return o + } } } } else if !errors.Is(err, library.ErrNoFPCalc) { @@ -183,6 +197,26 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome { return o } +// keepAsUpgrade — совпадение по отпечатку, но файл лучше библиотечного и режим замены включён. +// true — не считать дублем, оставить на рассмотрение. +func (s *Service) keepAsUpgrade(ctx context.Context, o *outcome, track *uuid.UUID, probe library.Probe) bool { + if track == nil { + return false + } + st, err := s.store.Settings(ctx) + if err != nil || !st.AllowQualityUpgrade { + return false + } + codec, format, bitrate, err := s.store.BestAudio(ctx, *track) + if err != nil || !BetterQuality(probe.Codec, probe.Format, probe.Bitrate, codec, format, bitrate) { + return false + } + o.quality = true + o.dupTrack = track + o.dupLevel = DupFingerprint + return true +} + // bestMatch — отпечаток, похожий не меньше порога library.SameRecording. func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) { best, score := PrintCandidate{}, 0.0 @@ -245,11 +279,11 @@ func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error { 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, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''), + 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.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality) return err }) } @@ -295,7 +329,7 @@ func (s *Service) importFile(ctx context.Context, f File) error { } rel := *pl.RelPath stagingRel, sum := f.StagingPath, f.SHA256 - if f.MetadataEdited { + if f.MetadataEdited || (f.CoverPath != nil && *f.CoverPath != "") { tagged, tsum, err := s.writeTags(ctx, f, *f.Meta, path.Ext(rel)) if err != nil { return fmt.Errorf("write tags: %w", err) @@ -352,7 +386,12 @@ func (s *Service) writeTags(ctx context.Context, f File, m Meta, ext string) (st } rel := f.StagingPath + ".tagged" + strings.ToLower(ext) out := s.stagingAbs(rel) - args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath), "-map", "0", "-c", "copy", "-map_metadata", "0"} + args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath)} + if f.CoverPath != nil && *f.CoverPath != "" { + args = append(args, "-i", s.stagingAbs(*f.CoverPath), "-map", "0:a", "-map", "1", "-c", "copy", "-disposition:v:0", "attached_pic", "-map_metadata", "0") + } else { + args = append(args, "-map", "0", "-c", "copy", "-map_metadata", "0") + } set := func(k, v string) { args = append(args, "-metadata", k+"="+v) } set("title", m.Title) set("artist", m.Artist) diff --git a/services/api/internal/wave/wave.go b/services/api/internal/wave/wave.go index 991aae7..713223d 100644 --- a/services/api/internal/wave/wave.go +++ b/services/api/internal/wave/wave.go @@ -141,6 +141,8 @@ type Handler struct { catalog *library.Store dto func() library.DTO log *slog.Logger + // Warm заранее готовит транскоды выданной пачки (docs/playback.md). + Warm func(ctx context.Context, trackIDs []uuid.UUID) } // NewHandler создаёт обработчик. @@ -328,6 +330,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave if err := h.record(ctx, s, picks, modelVer); err != nil { return gen.WaveBatch{}, err } + if h.Warm != nil && len(ids) > 0 { + go h.Warm(context.WithoutCancel(ctx), ids) + } return gen.WaveBatch{SessionId: s.ID, Activity: s.Activity, Mood: s.Mood, Tracks: tracks, Fallback: fallback}, nil } diff --git a/services/api/migrations/00011_upload_rest.sql b/services/api/migrations/00011_upload_rest.sql new file mode 100644 index 0000000..cc569ef --- /dev/null +++ b/services/api/migrations/00011_upload_rest.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Остаток очереди: замена на лучшее качество, обложка пачки, визит автора (docs/uploads.md). +ALTER TABLE upload_files ADD COLUMN quality_upgrade boolean NOT NULL DEFAULT false; +ALTER TABLE upload_batches ADD COLUMN cover_path text; + +CREATE TABLE upload_visits ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + seen_at timestamptz NOT NULL +); + +-- +goose Down +DROP TABLE upload_visits; +ALTER TABLE upload_batches DROP COLUMN cover_path; +ALTER TABLE upload_files DROP COLUMN quality_upgrade; diff --git a/tests/contract/schemathesis.sh b/tests/contract/schemathesis.sh new file mode 100644 index 0000000..3214aa3 --- /dev/null +++ b/tests/contract/schemathesis.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Фаззинг ответов api по OpenAPI. Сервер уже запущен; публичные ручки без токена, +# остальные — с PIPU_API_TOKEN (Bearer). +set -eu +: "${PIPU_API_URL:?set PIPU_API_URL, for example http://127.0.0.1:8080}" +root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +auth= +if [ -n "${PIPU_API_TOKEN:-}" ]; then + auth="Authorization: Bearer ${PIPU_API_TOKEN}" +fi +exec schemathesis run "${root}/api/openapi.yaml" \ + --url "$PIPU_API_URL" \ + --checks all \ + --max-examples "${PIPU_SCHEMA_EXAMPLES:-20}" \ + ${auth:+--header "$auth"}