mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
feat(downloads): add media quality and queue reordering
This commit is contained in:
@@ -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' })];
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user