mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 13:06:52 +00:00
fix(add-modal): harden playlist media selection
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendRequestUrlsAfterVersion,
|
||||
commonMediaFormatsForRows,
|
||||
canSubmitMetadataRows,
|
||||
commonMediaQualitiesForRows,
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaFormatForFormat,
|
||||
mediaQualityForRow,
|
||||
mediaTypeForFormat,
|
||||
metadataSummaryMessage,
|
||||
isYouTubePlaylistUrl,
|
||||
playlistFilePrefix,
|
||||
reconcileDownloadRows,
|
||||
refreshFailedMetadataRows,
|
||||
selectExactMediaSelection,
|
||||
selectExactMediaQuality,
|
||||
updateRowIfCurrent,
|
||||
type AddDownloadDraftRow
|
||||
@@ -562,6 +566,156 @@ describe('add download metadata workflow', () => {
|
||||
expect(mediaQualityForRow(rows[0])).toBe('1080p');
|
||||
});
|
||||
|
||||
it('keeps playlist choices available when one ready row remains selected', () => {
|
||||
const formats = [
|
||||
{
|
||||
name: '720p MP4',
|
||||
quality: '720p',
|
||||
selector: '720',
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4 • H.264',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
},
|
||||
{
|
||||
name: 'Audio only M4A',
|
||||
quality: 'Audio only',
|
||||
selector: 'audio',
|
||||
ext: 'm4a',
|
||||
formatLabel: 'M4A • AAC',
|
||||
detail: '4 MB',
|
||||
type: 'Audio',
|
||||
bytes: 4
|
||||
}
|
||||
];
|
||||
const selectedRow = row({ isMedia: true, formats, selectedFormat: 0 });
|
||||
|
||||
expect(commonMediaFormatsForRows([selectedRow], 'Video')).toEqual(['MP4']);
|
||||
expect(commonMediaQualitiesForRows([selectedRow], 'Video', 'MP4')).toEqual(['720p']);
|
||||
expect(commonMediaFormatsForRows([selectedRow], 'Audio')).toEqual(['M4A']);
|
||||
expect(commonMediaQualitiesForRows([selectedRow], 'Audio', 'M4A')).toEqual(['Audio only']);
|
||||
});
|
||||
|
||||
it('does not offer a shared format when its qualities do not overlap', () => {
|
||||
const format = (quality: string, id: string) => [{
|
||||
name: `${quality} MP4`,
|
||||
quality,
|
||||
selector: id,
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4 • H.264',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
}];
|
||||
const rows = [
|
||||
row({ id: 'one', isMedia: true, formats: format('1080p', 'one'), selectedFormat: 0 }),
|
||||
row({ id: 'two', isMedia: true, formats: format('720p', 'two'), selectedFormat: 0 })
|
||||
];
|
||||
|
||||
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters playlist choices by media type and format', () => {
|
||||
const formats = (qualities: string[], idPrefix: string) => [
|
||||
...qualities.flatMap((quality, index) => [
|
||||
{
|
||||
name: `${quality} MKV`,
|
||||
quality,
|
||||
selector: `${idPrefix}-mkv-${index}`,
|
||||
ext: 'mkv',
|
||||
formatLabel: 'MKV • H.264',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
},
|
||||
{
|
||||
name: `${quality} MP4`,
|
||||
quality,
|
||||
selector: `${idPrefix}-mp4-${index}`,
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4 • H.264',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
}
|
||||
]),
|
||||
{
|
||||
name: 'Audio only M4A',
|
||||
quality: 'Audio only',
|
||||
selector: `${idPrefix}-m4a`,
|
||||
ext: 'm4a',
|
||||
formatLabel: 'M4A • AAC',
|
||||
detail: '4 MB',
|
||||
type: 'Audio',
|
||||
bytes: 4
|
||||
},
|
||||
{
|
||||
name: 'Audio only WEBM',
|
||||
quality: 'Audio only',
|
||||
selector: `${idPrefix}-webm`,
|
||||
ext: 'webm',
|
||||
formatLabel: 'WEBM • Opus',
|
||||
detail: '4 MB',
|
||||
type: 'Audio',
|
||||
bytes: 4
|
||||
}
|
||||
];
|
||||
const rows = [
|
||||
row({ id: 'one', isMedia: true, formats: formats(['1080p', '720p'], 'one'), selectedFormat: 0 }),
|
||||
row({ id: 'two', isMedia: true, formats: formats(['720p', '480p'], 'two'), selectedFormat: 0 })
|
||||
];
|
||||
|
||||
expect(commonMediaFormatsForRows(rows, 'Video')).toEqual(['MKV', 'MP4']);
|
||||
expect(commonMediaQualitiesForRows(rows, 'Video', 'MP4')).toEqual(['720p']);
|
||||
expect(commonMediaFormatsForRows(rows, 'Audio')).toEqual(['M4A', 'WEBM']);
|
||||
expect(commonMediaQualitiesForRows(rows, 'Audio', 'M4A')).toEqual(['Audio only']);
|
||||
expect(mediaTypeForFormat(rows[0].formats![4])).toBe('Audio');
|
||||
expect(mediaFormatForFormat(rows[0].formats![4])).toBe('M4A');
|
||||
});
|
||||
|
||||
it('applies an exact playlist media selection without falling back to another format', () => {
|
||||
const formats = (id: string) => [
|
||||
{
|
||||
name: '720p MKV',
|
||||
quality: '720p',
|
||||
selector: `${id}-mkv`,
|
||||
ext: 'mkv',
|
||||
formatLabel: 'MKV • H.264',
|
||||
detail: '10 MB',
|
||||
type: 'Video',
|
||||
bytes: 10
|
||||
},
|
||||
{
|
||||
name: '720p MP4',
|
||||
quality: '720p',
|
||||
selector: `${id}-mp4`,
|
||||
ext: 'mp4',
|
||||
formatLabel: 'MP4 • H.264',
|
||||
detail: '9 MB',
|
||||
type: 'Video',
|
||||
bytes: 9
|
||||
}
|
||||
];
|
||||
const rows = [
|
||||
row({ id: 'one', isMedia: true, file: 'one.mkv', formats: formats('one'), selectedFormat: 0 }),
|
||||
row({ id: 'two', isMedia: true, file: 'two.mkv', formats: formats('two'), selectedFormat: 0 })
|
||||
];
|
||||
|
||||
const selected = selectExactMediaSelection(rows, ['one', 'two'], {
|
||||
mediaType: 'Video',
|
||||
format: 'MP4',
|
||||
quality: '720p'
|
||||
});
|
||||
expect(selected[0]).toMatchObject({ selectedFormat: 1, file: 'one.mp4' });
|
||||
expect(selected[1]).toMatchObject({ selectedFormat: 1, file: 'two.mp4' });
|
||||
expect(selectExactMediaSelection(rows, ['one', 'two'], {
|
||||
mediaType: 'Video',
|
||||
format: 'WEBM',
|
||||
quality: '720p'
|
||||
})).toEqual(rows);
|
||||
});
|
||||
|
||||
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
|
||||
const mediaRow = row({
|
||||
id: 'media',
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface AddMediaFormat {
|
||||
isApproximate?: boolean;
|
||||
}
|
||||
|
||||
export type MediaType = 'Audio' | 'Video';
|
||||
|
||||
export interface MediaSelection {
|
||||
mediaType: MediaType;
|
||||
format: string;
|
||||
quality: string;
|
||||
}
|
||||
|
||||
export interface AddDownloadDraftRow {
|
||||
id: string;
|
||||
sourceUrl: string;
|
||||
@@ -353,6 +361,14 @@ export const mediaQualityForFormat = (
|
||||
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
|
||||
};
|
||||
|
||||
export const mediaTypeForFormat = (
|
||||
format: Pick<AddMediaFormat, 'type'>
|
||||
): MediaType => format.type.toLowerCase().includes('audio') ? 'Audio' : 'Video';
|
||||
|
||||
export const mediaFormatForFormat = (
|
||||
format: Pick<AddMediaFormat, 'ext'>
|
||||
): string => normalizeMediaQualityLabel(format.ext.replace(/^\.+/, '').toUpperCase()) || 'MEDIA';
|
||||
|
||||
export const mediaQualityForRow = (
|
||||
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
|
||||
): string | undefined => {
|
||||
@@ -362,19 +378,47 @@ export const mediaQualityForRow = (
|
||||
};
|
||||
|
||||
export const commonMediaQualitiesForRows = (
|
||||
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>
|
||||
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
|
||||
mediaType?: MediaType,
|
||||
mediaFormat?: string
|
||||
): string[] => {
|
||||
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
||||
if (readyMediaRows.length < 2) return [];
|
||||
if (readyMediaRows.length < 1) return [];
|
||||
|
||||
const matches = (format: AddMediaFormat) =>
|
||||
(!mediaType || mediaTypeForFormat(format) === mediaType)
|
||||
&& (!mediaFormat || mediaFormatForFormat(format) === mediaFormat);
|
||||
|
||||
const firstQualities = Array.from(new Set(
|
||||
readyMediaRows[0].formats!.map(mediaQualityForFormat)
|
||||
readyMediaRows[0].formats!.filter(matches).map(mediaQualityForFormat)
|
||||
));
|
||||
return firstQualities.filter(quality => readyMediaRows.every(row =>
|
||||
row.formats!.some(format => mediaQualityForFormat(format) === quality)
|
||||
row.formats!.some(format => matches(format) && mediaQualityForFormat(format) === quality)
|
||||
));
|
||||
};
|
||||
|
||||
export const commonMediaFormatsForRows = (
|
||||
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
|
||||
mediaType: MediaType
|
||||
): string[] => {
|
||||
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
||||
if (readyMediaRows.length < 1) return [];
|
||||
|
||||
const firstFormats = Array.from(new Set(
|
||||
readyMediaRows[0].formats!
|
||||
.filter(format => mediaTypeForFormat(format) === mediaType)
|
||||
.map(mediaFormatForFormat)
|
||||
));
|
||||
return firstFormats.filter(mediaFormat =>
|
||||
readyMediaRows.every(row =>
|
||||
row.formats!.some(format =>
|
||||
mediaTypeForFormat(format) === mediaType && mediaFormatForFormat(format) === mediaFormat
|
||||
)
|
||||
)
|
||||
&& commonMediaQualitiesForRows(readyMediaRows, mediaType, mediaFormat).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
export const selectExactMediaQuality = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
selectedIds: ReadonlySet<string> | readonly string[],
|
||||
@@ -399,6 +443,34 @@ export const selectExactMediaQuality = (
|
||||
});
|
||||
};
|
||||
|
||||
export const selectExactMediaSelection = (
|
||||
rows: AddDownloadDraftRow[],
|
||||
selectedIds: ReadonlySet<string> | readonly string[],
|
||||
selection: MediaSelection
|
||||
): 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 =>
|
||||
mediaTypeForFormat(format) === selection.mediaType
|
||||
&& mediaFormatForFormat(format) === selection.format
|
||||
&& mediaQualityForFormat(format) === selection.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'>
|
||||
|
||||
Reference in New Issue
Block a user