feat(downloads): add media quality and queue reordering

This commit is contained in:
NimBold
2026-07-23 02:55:33 +03:30
parent f4e5e211cc
commit ceab8a5fdf
22 changed files with 869 additions and 16 deletions
+49
View File
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest';
import {
appendRequestUrlsAfterVersion,
canSubmitMetadataRows,
commonMediaQualitiesForRows,
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
mediaQualityForRow,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
selectExactMediaQuality,
updateRowIfCurrent,
type AddDownloadDraftRow
} from './addDownloadMetadata';
@@ -539,6 +542,52 @@ describe('add download metadata workflow', () => {
])).toContain('Correct or remove 1 invalid URL');
});
it('offers only qualities shared by every selected ready media row', () => {
const formats = (qualities: string[]) => qualities.map((quality, index) => ({
name: `${quality} MP4`,
quality,
selector: `${quality}-${index}`,
ext: 'mp4',
formatLabel: quality,
detail: '10 MB',
type: 'Video',
bytes: 10
}));
const rows = [
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p']), selectedFormat: 0 }),
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p']), selectedFormat: 0 })
];
expect(commonMediaQualitiesForRows(rows)).toEqual(['720p']);
expect(mediaQualityForRow(rows[0])).toBe('1080p');
});
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
const mediaRow = row({
id: 'media',
file: 'clip.mp4',
isMedia: true,
formats: [{
name: '1080p MP4',
quality: '1080p',
selector: '1080',
ext: 'mp4',
formatLabel: '1080p',
detail: '10 MB',
type: 'Video',
bytes: 10
}],
selectedFormat: 0
});
const unchanged = selectExactMediaQuality([mediaRow], ['media'], '720p');
expect(unchanged[0]).toBe(mediaRow);
expect(selectExactMediaQuality([mediaRow], ['media'], '1080p')[0]).toMatchObject({
selectedFormat: 0,
file: 'clip.mp4'
});
});
it('uses few forms for Russian and Ukrainian metadata summaries', async () => {
const originalLanguage = i18n.language;
const twoReadyRows = [row(), row({ id: 'row-2' })];
+68
View File
@@ -11,6 +11,7 @@ export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
export interface AddMediaFormat {
name: string;
quality?: string;
selector: string;
ext: string;
formatLabel: string;
@@ -331,6 +332,73 @@ export const mediaFormatSelectorForRow = (
return row.formats?.[row.selectedFormat]?.selector;
};
const normalizeMediaQualityLabel = (value: string | undefined): string => {
const normalized = value
?.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized && normalized.length <= 48 ? normalized : '';
};
export const mediaQualityForFormat = (
format: Pick<AddMediaFormat, 'quality' | 'name' | 'type' | 'formatLabel' | 'ext'>
): string => {
const explicitQuality = normalizeMediaQualityLabel(format.quality);
if (explicitQuality) return explicitQuality;
const nameQuality = normalizeMediaQualityLabel(format.name.trim().split(/\s+/)[0]);
if (nameQuality) return nameQuality;
const label = normalizeMediaQualityLabel(format.formatLabel);
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
};
export const mediaQualityForRow = (
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
): string | undefined => {
if (!row.isMedia || row.status !== 'ready' || row.selectedFormat === undefined) return undefined;
const format = row.formats?.[row.selectedFormat];
return format ? mediaQualityForFormat(format) : undefined;
};
export const commonMediaQualitiesForRows = (
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>
): string[] => {
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
if (readyMediaRows.length < 2) return [];
const firstQualities = Array.from(new Set(
readyMediaRows[0].formats!.map(mediaQualityForFormat)
));
return firstQualities.filter(quality => readyMediaRows.every(row =>
row.formats!.some(format => mediaQualityForFormat(format) === quality)
));
};
export const selectExactMediaQuality = (
rows: AddDownloadDraftRow[],
selectedIds: ReadonlySet<string> | readonly string[],
quality: string
): AddDownloadDraftRow[] => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
return rows.map(row => {
if (!selected.has(row.id) || !row.isMedia || row.status !== 'ready' || !row.formats) return row;
const selectedFormat = row.formats.findIndex(format => mediaQualityForFormat(format) === quality);
if (selectedFormat === -1) return row;
const format = row.formats[selectedFormat];
return {
...row,
selectedFormat,
size: format.bytes ? format.detail : undefined,
sizeBytes: format.bytes || undefined,
file: mediaFileNameForSelectedFormat(row.file, {
formats: row.formats,
selectedFormat
})
};
});
};
export const mediaFileNameForSelectedFormat = (
fileName: string,
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
moveSelectedBlockToIndex,
targetIndexForBoundary,
targetIndexForDesiredOrder
} from './queueOrdering';
const items = ['a', 'b', 'c', 'd'].map(id => ({ id }));
describe('queue ordering', () => {
it('moves discontiguous selection as one block', () => {
expect(moveSelectedBlockToIndex(items, ['b', 'd'], 1).map(item => item.id))
.toEqual(['a', 'b', 'd', 'c']);
});
it('translates pointer boundaries after selected rows are removed', () => {
expect(targetIndexForBoundary(items, ['b', 'd'], 2)).toBe(1);
expect(targetIndexForBoundary(items, ['b', 'd'], 3)).toBe(2);
});
it('computes a registered-only backend target from the desired local order', () => {
const current = [{ id: 'a' }, { id: 'staged' }, { id: 'b' }, { id: 'c' }];
const desired = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'staged' }];
expect(targetIndexForDesiredOrder(current, ['c'], desired)).toBe(2);
expect(targetIndexForDesiredOrder(
[{ id: 'a' }, { id: 'c' }],
['c'],
[{ id: 'a' }, { id: 'staged' }, { id: 'c' }]
)).toBe(1);
});
});
+61
View File
@@ -0,0 +1,61 @@
export interface QueueOrderItem {
id: string;
}
/**
* Move the selected rows as one block into an insertion position among the
* unselected rows. The position is intentionally defined after selection is
* removed so callers can use the same semantics for UI and backend queues.
*/
export const moveSelectedBlockToIndex = <T extends QueueOrderItem>(
items: T[],
selectedIds: ReadonlySet<string> | readonly string[],
targetIndex: number
): T[] => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const selectedItems = items.filter(item => selected.has(item.id));
const unselectedItems = items.filter(item => !selected.has(item.id));
const insertionIndex = Math.max(0, Math.min(targetIndex, unselectedItems.length));
return [
...unselectedItems.slice(0, insertionIndex),
...selectedItems,
...unselectedItems.slice(insertionIndex)
];
};
/**
* Convert a pointer boundary in the original list to the insertion index
* used by moveSelectedBlockToIndex. A boundary is between rows and ranges
* from 0 (before the first row) through items.length (after the last row).
*/
export const targetIndexForBoundary = <T extends QueueOrderItem>(
items: T[],
selectedIds: ReadonlySet<string> | readonly string[],
boundaryIndex: number
): number => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const boundary = Math.max(0, Math.min(boundaryIndex, items.length));
return items
.slice(0, boundary)
.reduce((count, item) => count + (selected.has(item.id) ? 0 : 1), 0);
};
/**
* Translate a desired local order to the backend's registered-only queue.
* Staged rows are not registered with the backend, so they must not affect
* the target index sent over IPC.
*/
export const targetIndexForDesiredOrder = <T extends QueueOrderItem>(
currentItems: T[],
selectedIds: ReadonlySet<string> | readonly string[],
desiredItems: T[]
): number => {
const selected = selectedIds instanceof Set ? selectedIds : new Set(selectedIds);
const currentIds = new Set(currentItems.map(item => item.id));
const firstSelectedIndex = desiredItems.findIndex(item => selected.has(item.id));
if (firstSelectedIndex === -1) return currentItems.filter(item => !selected.has(item.id)).length;
return desiredItems
.slice(0, firstSelectedIndex)
.reduce((count, item) => count + (selected.has(item.id) || !currentIds.has(item.id) ? 0 : 1), 0);
};