From 1e3a149aef300525c792acecbfb364db4f420637 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:41:28 +0300 Subject: [PATCH] feat(uploads): show batch format counts and quality ranges --- apps/web/e2e/upload-review.spec.ts | 5 +++- apps/web/src/features/uploads/QueueRows.tsx | 4 +++ .../src/features/uploads/batchSummary.test.ts | 21 +++++++++++++ apps/web/src/features/uploads/batchSummary.ts | 30 +++++++++++++++++++ docs/uploads.md | 2 ++ packages/i18n/locales/en/admin.json | 1 + packages/i18n/locales/ru/admin.json | 1 + 7 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/uploads/batchSummary.test.ts create mode 100644 apps/web/src/features/uploads/batchSummary.ts diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts index 3af6ad7..8083831 100644 --- a/apps/web/e2e/upload-review.spec.ts +++ b/apps/web/e2e/upload-review.spec.ts @@ -29,7 +29,8 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { { ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' }, { ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' }, ]; - const missing = { ...file, id: 'missing', original_name: 'missing.flac', target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' }; + files[0]!.bitrate = 778000; + const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' }; let failOnce = true; const deleted: string[] = []; const original = window.fetch; @@ -96,6 +97,8 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => { await setup(page, 'queue'); await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible(); + await expect(page.getByText('2 × FLAC', { exact: true })).toBeVisible(); + await expect(page.getByText('778–924 kbps · 44.1 kHz · 16 bit', { exact: true })).toBeVisible(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden(); await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible(); diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx index 8a1f473..ca5a8d5 100644 --- a/apps/web/src/features/uploads/QueueRows.tsx +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -3,6 +3,7 @@ import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; import { useId, useState } from 'react'; import { needsFormatAttention } from './files'; import { AudioPreview } from './AudioPreview'; +import { batchSummary } from './batchSummary'; import { UploadStatusBadge, useReasonText, type UploadFile } from './common'; import { useTranslation } from 'react-i18next'; import { Button, cx, Icon } from '../../shared/ui/ui'; @@ -32,6 +33,7 @@ export function BatchCard({ const ids = files.map((f) => f.id); const all = ids.every((id) => selected.has(id)); const size = files.reduce((n, f) => n + f.size_bytes, 0); + const summary = batchSummary(files); return (
@@ -44,6 +46,8 @@ export function BatchCard({ {first.author.username} {formatDateTime(first.created_at, i18n.language)} {t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })} + {summary.formats.map(([format, count]) => `${count} × ${format || t('uploads.format_unknown')}`).join(' · ')} + {summary.quality && {summary.quality}} {t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')} diff --git a/apps/web/src/features/uploads/batchSummary.test.ts b/apps/web/src/features/uploads/batchSummary.test.ts new file mode 100644 index 0000000..cc2a0d7 --- /dev/null +++ b/apps/web/src/features/uploads/batchSummary.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest'; +import { batchSummary } from './batchSummary'; + +it('counts formats and combines measured quality ranges without repeating equal values', () => { + const summary = batchSummary([ + { format: 'FLAC', original_name: 'one.flac', bitrate: 778000, sample_rate: 44100, bit_depth: 16 }, + { format: 'flac', original_name: 'two.flac', bitrate: 924000, sample_rate: 44100, bit_depth: 16 }, + { format: null, original_name: 'three.mp3', bitrate: 320000, sample_rate: 48000, bit_depth: null }, + ]); + expect(summary.formats).toEqual([['FLAC', 2], ['MP3', 1]]); + expect(summary.quality).toBe('320–924 kbps · 44.1–48 kHz · 16 bit'); +}); + +it('ignores missing and invalid measurements and retains fractional sample rates', () => { + expect(batchSummary([ + { format: null, original_name: 'unknown', bitrate: 0, sample_rate: null, bit_depth: null }, + { format: 'WAV', original_name: 'one.wav', bitrate: null, sample_rate: 11025, bit_depth: 24 }, + { format: 'wav', original_name: 'two.wav', bitrate: NaN, sample_rate: 96000, bit_depth: 32 }, + ])).toEqual({ formats: [['WAV', 2], ['', 1]], quality: '11.025–96 kHz · 24–32 bit' }); + expect(batchSummary([]).quality).toBe(''); +}); diff --git a/apps/web/src/features/uploads/batchSummary.ts b/apps/web/src/features/uploads/batchSummary.ts new file mode 100644 index 0000000..0096f61 --- /dev/null +++ b/apps/web/src/features/uploads/batchSummary.ts @@ -0,0 +1,30 @@ +import type { UploadFile } from './common'; +import { audioExt } from './files'; + +type SummaryFile = Pick; + +function range(values: (number | null)[], divisor: number, decimals: number, unit: string): string | null { + const known = values.filter((value): value is number => value !== null && Number.isFinite(value) && value > 0); + if (!known.length) return null; + const format = (value: number) => String(Number((value / divisor).toFixed(decimals))); + const min = format(Math.min(...known)); + const max = format(Math.max(...known)); + return `${min === max ? min : `${min}–${max}`} ${unit}`; +} + +/** Only measured positive values contribute to quality ranges; absent metadata is not zero. */ +export function batchSummary(files: SummaryFile[]) { + const counts = new Map(); + for (const file of files) { + const format = (file.format?.trim() || audioExt(file.original_name)).toUpperCase(); + counts.set(format, (counts.get(format) ?? 0) + 1); + } + return { + formats: [...counts].sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b)), + quality: [ + range(files.map((file) => file.bitrate), 1000, 0, 'kbps'), + range(files.map((file) => file.sample_rate), 1000, 3, 'kHz'), + range(files.map((file) => file.bit_depth), 1, 0, 'bit'), + ].filter(Boolean).join(' · '), + }; +} diff --git a/docs/uploads.md b/docs/uploads.md index c70ada3..8e5072d 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -159,6 +159,8 @@ - Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`. Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия. В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки». + Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности; + одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули. - Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество (формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище** с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 67e3c73..58b691f 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -296,6 +296,7 @@ "collapse_batch": "Collapse batch", "batch_cover_present": "Cover attached", "batch_cover_absent": "No cover", + "format_unknown": "unknown format", "preview_http_error": "Could not load audio: HTTP {{status}}.", "preview_decode_error": "The browser could not read the audio: check file access and format support.", "preview_error": "Could not load audio. Check your connection and retry.", diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 44ccde5..a2d32a5 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -296,6 +296,7 @@ "collapse_batch": "Свернуть пачку", "batch_cover_present": "Есть обложка", "batch_cover_absent": "Без обложки", + "format_unknown": "формат неизвестен", "preview_http_error": "Не удалось загрузить аудио: HTTP {{status}}.", "preview_decode_error": "Браузер не смог прочитать аудио: проверьте доступ к файлу и поддержку формата.", "preview_error": "Не удалось загрузить аудио. Проверьте соединение и повторите попытку.",