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", + "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", } // 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"}