feat(uploads): show batch format counts and quality ranges
This commit is contained in:
1 parent
e78cab6ce6
commit
1e3a149aef
7 files changed
+63
-1
No files matched your search
@@ -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(' · '),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -159,6 +159,8 @@
|
|||||||
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
||||||
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
|
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
|
||||||
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
|
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
|
||||||
|
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
|
||||||
|
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
|
||||||
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
||||||
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
||||||
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
|
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Не удалось загрузить аудио. Проверьте соединение и повторите попытку.",
|
||||||
|
|||||||
Reference in new issue
Block a user