feat(uploads): show batch format counts and quality ranges
ci / ts / ts (packages + web) (push) Failing after 5m56s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s

This commit is contained in:
mrleo1nid committed 2026-10-29 17:41:28 +03:00
1 parent e78cab6ce6
commit 1e3a149aef
7 files changed
+63 -1

No files matched your search

+4 -1
View File
@@ -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: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' }, { ...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; let failOnce = true;
const deleted: string[] = []; const deleted: string[] = [];
const original = window.fetch; 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) => { test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue'); await setup(page, 'queue');
await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible(); 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 expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click(); await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
@@ -3,6 +3,7 @@ import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { useId, useState } from 'react'; import { useId, useState } from 'react';
import { needsFormatAttention } from './files'; import { needsFormatAttention } from './files';
import { AudioPreview } from './AudioPreview'; import { AudioPreview } from './AudioPreview';
import { batchSummary } from './batchSummary';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common'; import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui'; import { Button, cx, Icon } from '../../shared/ui/ui';
@@ -32,6 +33,7 @@ export function BatchCard({
const ids = files.map((f) => f.id); const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id)); const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0); const size = files.reduce((n, f) => n + f.size_bytes, 0);
const summary = batchSummary(files);
return ( return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}> <section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}> <div className={u.batchHead}>
@@ -44,6 +46,8 @@ export function BatchCard({
<b>{first.author.username}</b> <b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span> <span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span> <span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
<span>{summary.formats.map(([format, count]) => `${count} × ${format || t('uploads.format_unknown')}`).join(' · ')}</span>
{summary.quality && <span>{summary.quality}</span>}
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}> <span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')} {t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
</span> </span>
@@ -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('');
});
@@ -0,0 +1,30 @@
import type { UploadFile } from './common';
import { audioExt } from './files';
type SummaryFile = Pick<UploadFile, 'format' | 'original_name' | 'bitrate' | 'sample_rate' | 'bit_depth'>;
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<string, number>();
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(' · '),
};
}
+2
View File
@@ -159,6 +159,8 @@
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`. - Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия. Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки». В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество - Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище** (формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
+1
View File
@@ -296,6 +296,7 @@
"collapse_batch": "Collapse batch", "collapse_batch": "Collapse batch",
"batch_cover_present": "Cover attached", "batch_cover_present": "Cover attached",
"batch_cover_absent": "No cover", "batch_cover_absent": "No cover",
"format_unknown": "unknown format",
"preview_http_error": "Could not load audio: HTTP {{status}}.", "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_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.", "preview_error": "Could not load audio. Check your connection and retry.",
+1
View File
@@ -296,6 +296,7 @@
"collapse_batch": "Свернуть пачку", "collapse_batch": "Свернуть пачку",
"batch_cover_present": "Есть обложка", "batch_cover_present": "Есть обложка",
"batch_cover_absent": "Без обложки", "batch_cover_absent": "Без обложки",
"format_unknown": "формат неизвестен",
"preview_http_error": "Не удалось загрузить аудио: HTTP {{status}}.", "preview_http_error": "Не удалось загрузить аудио: HTTP {{status}}.",
"preview_decode_error": "Браузер не смог прочитать аудио: проверьте доступ к файлу и поддержку формата.", "preview_decode_error": "Браузер не смог прочитать аудио: проверьте доступ к файлу и поддержку формата.",
"preview_error": "Не удалось загрузить аудио. Проверьте соединение и повторите попытку.", "preview_error": "Не удалось загрузить аудио. Проверьте соединение и повторите попытку.",