mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 11:07:08 +00:00
fix(add-modal): harden playlist media selection
This commit is contained in:
@@ -33,17 +33,22 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/
|
|||||||
import {
|
import {
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
|
commonMediaFormatsForRows,
|
||||||
commonMediaQualitiesForRows,
|
commonMediaQualitiesForRows,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
|
mediaFormatForFormat,
|
||||||
|
mediaQualityForFormat,
|
||||||
mediaQualityForRow,
|
mediaQualityForRow,
|
||||||
|
mediaTypeForFormat,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
metadataSummaryState,
|
metadataSummaryState,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
selectExactMediaQuality,
|
selectExactMediaSelection,
|
||||||
updateRowIfCurrent,
|
updateRowIfCurrent,
|
||||||
type AddDownloadDraftRow
|
type AddDownloadDraftRow,
|
||||||
|
type MediaSelection
|
||||||
} from '../utils/addDownloadMetadata';
|
} from '../utils/addDownloadMetadata';
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
@@ -189,6 +194,9 @@ export const AddDownloadsModal = () => {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||||
|
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
|
||||||
|
const playlistMediaSelectionsRef = useRef(new Map<string, MediaSelection>());
|
||||||
|
const [playlistMediaTypeSelections, setPlaylistMediaTypeSelections] = useState<Record<string, MediaSelection['mediaType']>>({});
|
||||||
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
const [checksumEnabled, setChecksumEnabled] = useState(false);
|
||||||
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
|
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
|
||||||
const [checksumValue, setChecksumValue] = useState('');
|
const [checksumValue, setChecksumValue] = useState('');
|
||||||
@@ -248,6 +256,9 @@ export const AddDownloadsModal = () => {
|
|||||||
setPlaylistExpansions({});
|
setPlaylistExpansions({});
|
||||||
playlistRequestsRef.current.clear();
|
playlistRequestsRef.current.clear();
|
||||||
latestPlaylistRequestRef.current.clear();
|
latestPlaylistRequestRef.current.clear();
|
||||||
|
playlistMediaSelectionsRef.current.clear();
|
||||||
|
setPlaylistMediaTypeSelections({});
|
||||||
|
setPlaylistQualityExpanded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +287,9 @@ export const AddDownloadsModal = () => {
|
|||||||
metadataRequestsRef.current.clear();
|
metadataRequestsRef.current.clear();
|
||||||
playlistRequestsRef.current.clear();
|
playlistRequestsRef.current.clear();
|
||||||
latestPlaylistRequestRef.current.clear();
|
latestPlaylistRequestRef.current.clear();
|
||||||
|
playlistMediaSelectionsRef.current.clear();
|
||||||
|
setPlaylistMediaTypeSelections({});
|
||||||
|
setPlaylistQualityExpanded(true);
|
||||||
setSelectedItemIndex(null);
|
setSelectedItemIndex(null);
|
||||||
setPendingUseSharedDestination(false);
|
setPendingUseSharedDestination(false);
|
||||||
setPendingDestinationOverrides({});
|
setPendingDestinationOverrides({});
|
||||||
@@ -325,6 +339,8 @@ export const AddDownloadsModal = () => {
|
|||||||
// arrives instead of reusing entries extracted under stale cookies.
|
// arrives instead of reusing entries extracted under stale cookies.
|
||||||
setPlaylistExpansions({});
|
setPlaylistExpansions({});
|
||||||
latestPlaylistRequestRef.current.clear();
|
latestPlaylistRequestRef.current.clear();
|
||||||
|
playlistMediaSelectionsRef.current.clear();
|
||||||
|
setPlaylistMediaTypeSelections({});
|
||||||
setUrls(current => appendRequestUrlsAfterVersion(
|
setUrls(current => appendRequestUrlsAfterVersion(
|
||||||
current,
|
current,
|
||||||
pendingAddRequestContexts,
|
pendingAddRequestContexts,
|
||||||
@@ -386,6 +402,17 @@ export const AddDownloadsModal = () => {
|
|||||||
latestPlaylistRequestRef.current.delete(sourceUrl);
|
latestPlaylistRequestRef.current.delete(sourceUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const sourceUrl of playlistMediaSelectionsRef.current.keys()) {
|
||||||
|
if (!activeUrls.has(sourceUrl)) {
|
||||||
|
playlistMediaSelectionsRef.current.delete(sourceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPlaylistMediaTypeSelections(current => {
|
||||||
|
const retained = Object.fromEntries(
|
||||||
|
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
|
||||||
|
);
|
||||||
|
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
|
||||||
|
});
|
||||||
setPlaylistExpansions(current => {
|
setPlaylistExpansions(current => {
|
||||||
const retained = Object.fromEntries(
|
const retained = Object.fromEntries(
|
||||||
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
|
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
|
||||||
@@ -532,6 +559,26 @@ export const AddDownloadsModal = () => {
|
|||||||
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
type: quality.toLowerCase().includes('audio') ? 'Audio' : 'Video'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const requestedSelection = row.playlistSourceUrl
|
||||||
|
? playlistMediaSelectionsRef.current.get(row.playlistSourceUrl)
|
||||||
|
: undefined;
|
||||||
|
const requestedFormatIndex = requestedSelection
|
||||||
|
? mappedFormats.findIndex(format =>
|
||||||
|
mediaTypeForFormat(format) === requestedSelection.mediaType
|
||||||
|
&& mediaFormatForFormat(format) === requestedSelection.format
|
||||||
|
&& mediaQualityForFormat(format) === requestedSelection.quality
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const fallbackFormatIndex = requestedSelection
|
||||||
|
? mappedFormats.findIndex(format =>
|
||||||
|
mediaTypeForFormat(format) === requestedSelection.mediaType
|
||||||
|
&& mediaQualityForFormat(format) === requestedSelection.quality
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
const selectedFormatIndex = requestedFormatIndex >= 0
|
||||||
|
? requestedFormatIndex
|
||||||
|
: fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0;
|
||||||
|
const selectedFormat = mappedFormats[selectedFormatIndex];
|
||||||
setParsedItems(current => updateRowIfCurrent(
|
setParsedItems(current => updateRowIfCurrent(
|
||||||
current,
|
current,
|
||||||
row.id,
|
row.id,
|
||||||
@@ -541,13 +588,13 @@ export const AddDownloadsModal = () => {
|
|||||||
...currentRow,
|
...currentRow,
|
||||||
downloadUrl: row.sourceUrl,
|
downloadUrl: row.sourceUrl,
|
||||||
file: canonicalizeDownloadFileName(
|
file: canonicalizeDownloadFileName(
|
||||||
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
|
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${selectedFormat.ext}`
|
||||||
),
|
),
|
||||||
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
size: selectedFormat.bytes ? selectedFormat.detail : undefined,
|
||||||
sizeBytes: mappedFormats[0].bytes || undefined,
|
sizeBytes: selectedFormat.bytes || undefined,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
formats: mappedFormats,
|
formats: mappedFormats,
|
||||||
selectedFormat: 0,
|
selectedFormat: selectedFormatIndex,
|
||||||
playlistError: undefined
|
playlistError: undefined
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
@@ -1267,11 +1314,23 @@ export const AddDownloadsModal = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPlaylistMediaSelection = (sourceUrl: string | undefined) => {
|
||||||
|
if (!sourceUrl) return;
|
||||||
|
playlistMediaSelectionsRef.current.delete(sourceUrl);
|
||||||
|
setPlaylistMediaTypeSelections(current => {
|
||||||
|
if (!(sourceUrl in current)) return current;
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[sourceUrl];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const selectMediaFormat = (index: number) => {
|
const selectMediaFormat = (index: number) => {
|
||||||
if (selectedItemIndex === null) return;
|
if (selectedItemIndex === null) return;
|
||||||
const selectedItem = parsedItems[selectedItemIndex];
|
const selectedItem = parsedItems[selectedItemIndex];
|
||||||
const format = selectedItem?.formats?.[index];
|
const format = selectedItem?.formats?.[index];
|
||||||
if (!selectedItem || !format) return;
|
if (!selectedItem || !format) return;
|
||||||
|
clearPlaylistMediaSelection(selectedItem.playlistSourceUrl);
|
||||||
|
|
||||||
setParsedItems(items => items.map((item, itemIndex) =>
|
setParsedItems(items => items.map((item, itemIndex) =>
|
||||||
itemIndex === selectedItemIndex
|
itemIndex === selectedItemIndex
|
||||||
@@ -1290,13 +1349,119 @@ export const AddDownloadsModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
const selectedItems = parsedItems.filter(item => item.selected !== false);
|
||||||
const selectedReadyMediaItems = selectedItems.filter(item =>
|
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||||
|
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||||
|
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
||||||
|
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
|
||||||
|
: [];
|
||||||
|
const selectedPlaylistReadyRows = selectedPlaylistRows.filter(item =>
|
||||||
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
|
item.isMedia && item.status === 'ready' && Boolean(item.formats?.length)
|
||||||
);
|
);
|
||||||
const bulkQualityOptions = commonMediaQualitiesForRows(selectedReadyMediaItems);
|
const selectedPlaylistFormatObject = selectedItem?.formats?.[selectedItem.selectedFormat ?? -1]
|
||||||
const applyBulkMediaQuality = (quality: string) => {
|
|| selectedPlaylistReadyRows[0]?.formats?.[selectedPlaylistReadyRows[0].selectedFormat ?? -1];
|
||||||
const selectedIds = selectedReadyMediaItems.map(item => item.id);
|
const detectedPlaylistMediaType = selectedPlaylistFormatObject
|
||||||
setParsedItems(items => selectExactMediaQuality(items, selectedIds, quality));
|
? mediaTypeForFormat(selectedPlaylistFormatObject)
|
||||||
|
: 'Video';
|
||||||
|
const playlistVideoFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Video');
|
||||||
|
const playlistAudioFormatOptions = commonMediaFormatsForRows(selectedPlaylistRows, 'Audio');
|
||||||
|
const requestedPlaylistMediaType = selectedPlaylistSourceUrl
|
||||||
|
? playlistMediaTypeSelections[selectedPlaylistSourceUrl]
|
||||||
|
: undefined;
|
||||||
|
const selectedPlaylistMediaType = requestedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
|
||||||
|
? 'Audio'
|
||||||
|
: requestedPlaylistMediaType === 'Video' && playlistVideoFormatOptions.length > 0
|
||||||
|
? 'Video'
|
||||||
|
: detectedPlaylistMediaType === 'Audio' && playlistAudioFormatOptions.length > 0
|
||||||
|
? 'Audio'
|
||||||
|
: playlistVideoFormatOptions.length > 0 ? 'Video' : 'Audio';
|
||||||
|
const playlistFormatOptions = selectedPlaylistMediaType === 'Audio'
|
||||||
|
? playlistAudioFormatOptions
|
||||||
|
: playlistVideoFormatOptions;
|
||||||
|
const hasPlaylistFormatOptions = playlistVideoFormatOptions.length > 0
|
||||||
|
|| playlistAudioFormatOptions.length > 0;
|
||||||
|
const currentPlaylistFormat = selectedPlaylistFormatObject
|
||||||
|
? mediaFormatForFormat(selectedPlaylistFormatObject)
|
||||||
|
: undefined;
|
||||||
|
const selectedPlaylistFormat = currentPlaylistFormat && playlistFormatOptions.includes(currentPlaylistFormat)
|
||||||
|
? currentPlaylistFormat
|
||||||
|
: playlistFormatOptions[0];
|
||||||
|
const playlistQualityOptions = selectedPlaylistFormat
|
||||||
|
? commonMediaQualitiesForRows(
|
||||||
|
selectedPlaylistRows,
|
||||||
|
selectedPlaylistMediaType,
|
||||||
|
selectedPlaylistFormat
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const playlistSelectionForRow = (row: AddDownloadDraftRow): MediaSelection | undefined => {
|
||||||
|
const format = row.formats?.[row.selectedFormat ?? -1];
|
||||||
|
return format ? {
|
||||||
|
mediaType: mediaTypeForFormat(format),
|
||||||
|
format: mediaFormatForFormat(format),
|
||||||
|
quality: mediaQualityForFormat(format)
|
||||||
|
} : undefined;
|
||||||
|
};
|
||||||
|
const appliedPlaylistSelection = selectedPlaylistReadyRows.length > 0
|
||||||
|
? (() => {
|
||||||
|
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
|
||||||
|
return firstSelection && selectedPlaylistReadyRows.every(row => {
|
||||||
|
const selection = playlistSelectionForRow(row);
|
||||||
|
return selection?.mediaType === firstSelection.mediaType
|
||||||
|
&& selection.format === firstSelection.format
|
||||||
|
&& selection.quality === firstSelection.quality;
|
||||||
|
})
|
||||||
|
? firstSelection
|
||||||
|
: undefined;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
const appliedPlaylistFormat = selectedPlaylistReadyRows.length > 0
|
||||||
|
? (() => {
|
||||||
|
const firstSelection = playlistSelectionForRow(selectedPlaylistReadyRows[0]);
|
||||||
|
return firstSelection
|
||||||
|
&& firstSelection.mediaType === selectedPlaylistMediaType
|
||||||
|
&& selectedPlaylistReadyRows.every(row => {
|
||||||
|
const selection = playlistSelectionForRow(row);
|
||||||
|
return selection?.mediaType === selectedPlaylistMediaType
|
||||||
|
&& selection.format === firstSelection.format;
|
||||||
|
})
|
||||||
|
? firstSelection.format
|
||||||
|
: undefined;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
const applyPlaylistMediaSelection = (selection: MediaSelection) => {
|
||||||
|
if (!selectedPlaylistSourceUrl) return;
|
||||||
|
playlistMediaSelectionsRef.current.set(selectedPlaylistSourceUrl, selection);
|
||||||
|
setPlaylistMediaTypeSelections(current => ({
|
||||||
|
...current,
|
||||||
|
[selectedPlaylistSourceUrl]: selection.mediaType
|
||||||
|
}));
|
||||||
|
const selectedIds = selectedPlaylistReadyRows.map(item => item.id);
|
||||||
|
setParsedItems(items => selectExactMediaSelection(items, selectedIds, selection));
|
||||||
|
};
|
||||||
|
const applyPlaylistMediaQuality = (quality: string) => {
|
||||||
|
if (!selectedPlaylistFormat) return;
|
||||||
|
applyPlaylistMediaSelection({
|
||||||
|
mediaType: selectedPlaylistMediaType,
|
||||||
|
format: selectedPlaylistFormat,
|
||||||
|
quality
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const applyPlaylistMediaFormat = (format: string) => {
|
||||||
|
const qualityOptions = commonMediaQualitiesForRows(
|
||||||
|
selectedPlaylistRows,
|
||||||
|
selectedPlaylistMediaType,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
const currentQuality = appliedPlaylistSelection?.quality
|
||||||
|
|| mediaQualityForRow(selectedPlaylistReadyRows[0]);
|
||||||
|
const quality = currentQuality && qualityOptions.includes(currentQuality)
|
||||||
|
? currentQuality
|
||||||
|
: qualityOptions[0];
|
||||||
|
if (!quality) return;
|
||||||
|
applyPlaylistMediaSelection({
|
||||||
|
mediaType: selectedPlaylistMediaType,
|
||||||
|
format,
|
||||||
|
quality
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
|
||||||
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
|
||||||
@@ -1574,34 +1739,6 @@ export const AddDownloadsModal = () => {
|
|||||||
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
<div className="add-download-settings w-[45%] flex flex-col overflow-y-auto">
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
|
||||||
{selectedReadyMediaItems.length > 1 ? (
|
|
||||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
|
||||||
<div className="flex flex-col gap-1.5 relative z-10">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">
|
|
||||||
{t($ => $.addDownloads.applyQualityToSelected)}
|
|
||||||
</label>
|
|
||||||
{bulkQualityOptions.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t($ => $.addDownloads.applyQualityToSelected)}>
|
|
||||||
{bulkQualityOptions.map(quality => (
|
|
||||||
<button
|
|
||||||
key={quality}
|
|
||||||
type="button"
|
|
||||||
onClick={() => applyBulkMediaQuality(quality)}
|
|
||||||
className="add-download-quality-chip add-download-quality-chip-button"
|
|
||||||
>
|
|
||||||
{quality}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[10px] text-text-muted">
|
|
||||||
{t($ => $.addDownloads.noCommonQuality)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Media Format (Dynamic) */}
|
{/* Media Format (Dynamic) */}
|
||||||
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
|
||||||
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
|
||||||
@@ -1666,6 +1803,125 @@ export const AddDownloadsModal = () => {
|
|||||||
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
|
<span className="text-xs text-red-400 font-medium">{t($ => $.addDownloads.metadataUnavailable)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedPlaylistSourceUrl && selectedPlaylistReadyRows.length > 0 ? (
|
||||||
|
<div className="add-download-playlist-quality relative z-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-download-playlist-quality-toggle"
|
||||||
|
aria-expanded={playlistQualityExpanded}
|
||||||
|
onClick={() => setPlaylistQualityExpanded(expanded => !expanded)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 text-start">
|
||||||
|
<span className="block text-xs font-semibold text-text-primary">
|
||||||
|
{t($ => $.addDownloads.playlistQuality)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-[10px] text-text-muted">
|
||||||
|
{t($ => $.addDownloads.playlistQualitySelected, {
|
||||||
|
selected: selectedPlaylistRows.length,
|
||||||
|
total: parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl).length
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{playlistQualityExpanded ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{playlistQualityExpanded ? (
|
||||||
|
hasPlaylistFormatOptions ? (
|
||||||
|
<>
|
||||||
|
{playlistVideoFormatOptions.length > 0 && playlistAudioFormatOptions.length > 0 ? (
|
||||||
|
<div className="add-download-playlist-media-type" role="tablist" aria-label={t($ => $.addDownloads.mediaFormat)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedPlaylistMediaType === 'Video'}
|
||||||
|
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
|
||||||
|
...current,
|
||||||
|
[selectedPlaylistSourceUrl]: 'Video'
|
||||||
|
}))}
|
||||||
|
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Video' ? 'is-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<Film size={13} /> {t($ => $.addDownloads.video)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selectedPlaylistMediaType === 'Audio'}
|
||||||
|
onClick={() => selectedPlaylistSourceUrl && setPlaylistMediaTypeSelections(current => ({
|
||||||
|
...current,
|
||||||
|
[selectedPlaylistSourceUrl]: 'Audio'
|
||||||
|
}))}
|
||||||
|
className={`add-download-playlist-media-type-option ${selectedPlaylistMediaType === 'Audio' ? 'is-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<Music size={13} /> {t($ => $.addDownloads.audio)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="add-download-playlist-quality-options mt-2">
|
||||||
|
<div className="add-download-playlist-quality-column">
|
||||||
|
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.quality)}</div>
|
||||||
|
{playlistQualityOptions.length > 0 ? (
|
||||||
|
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.quality)}>
|
||||||
|
{playlistQualityOptions.map(quality => {
|
||||||
|
const isApplied = appliedPlaylistSelection?.mediaType === selectedPlaylistMediaType
|
||||||
|
&& appliedPlaylistSelection.format === selectedPlaylistFormat
|
||||||
|
&& appliedPlaylistSelection.quality === quality;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={quality}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isApplied}
|
||||||
|
onClick={() => applyPlaylistMediaQuality(quality)}
|
||||||
|
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="add-download-quality-option-radio" aria-hidden="true" />
|
||||||
|
<span className="flex-1 text-start">{quality}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-text-muted">
|
||||||
|
{t($ => $.addDownloads.noCommonQuality)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playlistFormatOptions.length > 0 ? (
|
||||||
|
<div className="add-download-playlist-quality-column">
|
||||||
|
<div className="add-download-playlist-quality-label">{t($ => $.addDownloads.format)}</div>
|
||||||
|
<div className="add-download-playlist-quality-list" role="radiogroup" aria-label={t($ => $.addDownloads.format)}>
|
||||||
|
{playlistFormatOptions.map(format => {
|
||||||
|
const isApplied = appliedPlaylistFormat === format;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isApplied}
|
||||||
|
onClick={() => applyPlaylistMediaFormat(format)}
|
||||||
|
className={`add-download-quality-option ${isApplied ? 'is-selected' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="add-download-quality-option-radio" aria-hidden="true" />
|
||||||
|
<span className="flex-1 text-start font-mono">{format}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-2 text-[10px] text-text-muted">
|
||||||
|
{t($ => $.addDownloads.noCommonFormat)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const common = {
|
|||||||
availableStreams: 'Available Streams',
|
availableStreams: 'Available Streams',
|
||||||
availableMediaStreams: 'Available media streams',
|
availableMediaStreams: 'Available media streams',
|
||||||
quality: 'Quality',
|
quality: 'Quality',
|
||||||
applyQualityToSelected: 'Apply exact quality to selected media',
|
format: 'Format',
|
||||||
noCommonQuality: 'No exact quality is available for every selected media item.',
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
noCommonFormat: 'No single format is available for all selected items.',
|
||||||
|
noCommonQuality: 'No single quality is available for all selected items.',
|
||||||
|
playlistQuality: 'Playlist quality',
|
||||||
|
playlistQualitySelected: '{{selected}} of {{total}} selected',
|
||||||
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
|
metadataUnavailable: 'Metadata unavailable. Refresh metadata before adding this media.',
|
||||||
saveLocation: 'Save Location',
|
saveLocation: 'Save Location',
|
||||||
browse: 'Browse',
|
browse: 'Browse',
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const fa = {
|
|||||||
availableStreams: 'جریانهای موجود',
|
availableStreams: 'جریانهای موجود',
|
||||||
availableMediaStreams: 'جریانهای رسانه موجود',
|
availableMediaStreams: 'جریانهای رسانه موجود',
|
||||||
quality: 'کیفیت',
|
quality: 'کیفیت',
|
||||||
applyQualityToSelected: 'اعمال کیفیت دقیق برای رسانههای انتخابشده',
|
format: 'قالب',
|
||||||
noCommonQuality: 'کیفیت دقیقی برای همه رسانههای انتخابشده وجود ندارد.',
|
video: 'ویدئو',
|
||||||
|
audio: 'صوتی',
|
||||||
|
noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخابشده وجود ندارد.',
|
||||||
|
noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخابشده وجود ندارد.',
|
||||||
|
playlistQuality: 'کیفیت فهرست پخش',
|
||||||
|
playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده',
|
||||||
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازهسازی کنید.',
|
metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازهسازی کنید.',
|
||||||
saveLocation: 'محل ذخیره',
|
saveLocation: 'محل ذخیره',
|
||||||
browse: 'انتخاب…',
|
browse: 'انتخاب…',
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const he = {
|
|||||||
availableStreams: 'זרמים זמינים',
|
availableStreams: 'זרמים זמינים',
|
||||||
availableMediaStreams: 'זרמי מדיה זמינים',
|
availableMediaStreams: 'זרמי מדיה זמינים',
|
||||||
quality: 'איכות',
|
quality: 'איכות',
|
||||||
applyQualityToSelected: 'החלת איכות מדויקת על המדיה שנבחרה',
|
format: 'פורמט',
|
||||||
noCommonQuality: 'אין איכות מדויקת הזמינה לכל פריטי המדיה שנבחרו.',
|
video: 'וידאו',
|
||||||
|
audio: 'שמע',
|
||||||
|
noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.',
|
||||||
|
noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.',
|
||||||
|
playlistQuality: 'איכות רשימת השמעה',
|
||||||
|
playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו',
|
||||||
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
|
metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.',
|
||||||
saveLocation: 'מיקום שמירה',
|
saveLocation: 'מיקום שמירה',
|
||||||
browse: 'עיון…',
|
browse: 'עיון…',
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const ru = {
|
|||||||
availableStreams: 'Доступные потоки',
|
availableStreams: 'Доступные потоки',
|
||||||
availableMediaStreams: 'Доступные медиапотоки',
|
availableMediaStreams: 'Доступные медиапотоки',
|
||||||
quality: 'Качество',
|
quality: 'Качество',
|
||||||
applyQualityToSelected: 'Применить точное качество к выбранным медиа',
|
format: 'Формат',
|
||||||
noCommonQuality: 'Для всех выбранных медиа нет одинакового качества.',
|
video: 'Видео',
|
||||||
|
audio: 'Аудио',
|
||||||
|
noCommonFormat: 'Для всех выбранных элементов нет общего формата.',
|
||||||
|
noCommonQuality: 'Для всех выбранных элементов нет общего качества.',
|
||||||
|
playlistQuality: 'Качество плейлиста',
|
||||||
|
playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}',
|
||||||
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
|
metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.',
|
||||||
saveLocation: 'Папка сохранения',
|
saveLocation: 'Папка сохранения',
|
||||||
browse: 'Обзор…',
|
browse: 'Обзор…',
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const uk = {
|
|||||||
availableStreams: 'Доступні потоки',
|
availableStreams: 'Доступні потоки',
|
||||||
availableMediaStreams: 'Доступні медіапотоки',
|
availableMediaStreams: 'Доступні медіапотоки',
|
||||||
quality: 'Якість',
|
quality: 'Якість',
|
||||||
applyQualityToSelected: 'Застосувати точну якість до вибраних медіа',
|
format: 'Формат',
|
||||||
noCommonQuality: 'Для всіх вибраних медіа немає однакової якості.',
|
video: 'Відео',
|
||||||
|
audio: 'Аудіо',
|
||||||
|
noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.',
|
||||||
|
noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.',
|
||||||
|
playlistQuality: 'Якість плейлиста',
|
||||||
|
playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}',
|
||||||
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
|
metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.',
|
||||||
saveLocation: 'Місце збереження',
|
saveLocation: 'Місце збереження',
|
||||||
browse: 'Огляд…',
|
browse: 'Огляд…',
|
||||||
|
|||||||
@@ -472,8 +472,13 @@ const zhCN = {
|
|||||||
availableStreams: '可用流',
|
availableStreams: '可用流',
|
||||||
availableMediaStreams: '可用的媒体流',
|
availableMediaStreams: '可用的媒体流',
|
||||||
quality: '画质',
|
quality: '画质',
|
||||||
applyQualityToSelected: '将精确画质应用到选中的媒体',
|
format: '格式',
|
||||||
noCommonQuality: '所选媒体没有共同的精确画质。',
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
noCommonFormat: '所选项目没有共同格式。',
|
||||||
|
noCommonQuality: '所选项目没有共同画质。',
|
||||||
|
playlistQuality: '播放列表画质',
|
||||||
|
playlistQualitySelected: '已选择 {{selected}} / {{total}}',
|
||||||
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
|
metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。',
|
||||||
saveLocation: '保存位置',
|
saveLocation: '保存位置',
|
||||||
browse: '浏览',
|
browse: '浏览',
|
||||||
|
|||||||
+141
-11
@@ -794,6 +794,147 @@ html[data-list-density="relaxed"] {
|
|||||||
box-shadow: inset 3px 0 0 hsl(272 74% 62% / 0.9);
|
box-shadow: inset 3px 0 0 hsl(272 74% 62% / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid hsl(var(--border-modal));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
text-align: start;
|
||||||
|
transition: background-color 100ms ease, border-color 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-toggle:hover {
|
||||||
|
border-color: hsl(var(--border-modal));
|
||||||
|
background: hsl(var(--item-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-media-type {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid hsl(var(--border-modal));
|
||||||
|
border-radius: 7px;
|
||||||
|
background: hsl(var(--bg-input) / 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-media-type-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 26px;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-media-type-option:hover {
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-media-type-option:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--accent-color) / 0.62);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-media-type-option.is-selected {
|
||||||
|
background: hsl(var(--surface-raised) / 0.9);
|
||||||
|
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.35);
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-label {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 166px;
|
||||||
|
gap: 3px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-inline-end: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-playlist-quality-toggle:focus-visible,
|
||||||
|
.add-download-quality-option:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--accent-color) / 0.62);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid hsl(var(--border-modal));
|
||||||
|
border-radius: 7px;
|
||||||
|
background: hsl(var(--bg-input) / 0.68);
|
||||||
|
color: hsl(var(--text-secondary));
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: start;
|
||||||
|
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-option:hover {
|
||||||
|
border-color: hsl(var(--text-muted) / 0.58);
|
||||||
|
background: hsl(var(--item-hover));
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-option.is-selected {
|
||||||
|
border-color: hsl(var(--accent-color) / 0.48);
|
||||||
|
background: hsl(var(--accent-color) / 0.1);
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-option-radio {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid hsl(var(--text-muted) / 0.72);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-download-quality-option.is-selected .add-download-quality-option-radio {
|
||||||
|
border-color: hsl(var(--accent-color));
|
||||||
|
background: hsl(var(--accent-color));
|
||||||
|
box-shadow: inset 0 0 0 3px hsl(var(--bg-input));
|
||||||
|
}
|
||||||
|
|
||||||
.add-download-quality-chip,
|
.add-download-quality-chip,
|
||||||
.download-quality-chip {
|
.download-quality-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -810,17 +951,6 @@ html[data-list-density="relaxed"] {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-download-quality-chip-button {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 100ms ease, border-color 100ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-download-quality-chip-button:hover,
|
|
||||||
.add-download-quality-chip-button:focus-visible {
|
|
||||||
border-color: hsl(272 74% 62% / 0.72);
|
|
||||||
background: hsl(272 74% 58% / 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-download-select {
|
.add-download-select {
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
appendRequestUrlsAfterVersion,
|
appendRequestUrlsAfterVersion,
|
||||||
|
commonMediaFormatsForRows,
|
||||||
canSubmitMetadataRows,
|
canSubmitMetadataRows,
|
||||||
commonMediaQualitiesForRows,
|
commonMediaQualitiesForRows,
|
||||||
mediaFormatSelectorForRow,
|
mediaFormatSelectorForRow,
|
||||||
mediaFileNameForSelectedFormat,
|
mediaFileNameForSelectedFormat,
|
||||||
|
mediaFormatForFormat,
|
||||||
mediaQualityForRow,
|
mediaQualityForRow,
|
||||||
|
mediaTypeForFormat,
|
||||||
metadataSummaryMessage,
|
metadataSummaryMessage,
|
||||||
isYouTubePlaylistUrl,
|
isYouTubePlaylistUrl,
|
||||||
playlistFilePrefix,
|
playlistFilePrefix,
|
||||||
reconcileDownloadRows,
|
reconcileDownloadRows,
|
||||||
refreshFailedMetadataRows,
|
refreshFailedMetadataRows,
|
||||||
|
selectExactMediaSelection,
|
||||||
selectExactMediaQuality,
|
selectExactMediaQuality,
|
||||||
updateRowIfCurrent,
|
updateRowIfCurrent,
|
||||||
type AddDownloadDraftRow
|
type AddDownloadDraftRow
|
||||||
@@ -562,6 +566,156 @@ describe('add download metadata workflow', () => {
|
|||||||
expect(mediaQualityForRow(rows[0])).toBe('1080p');
|
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', () => {
|
it('applies an exact bulk quality without falling back to a higher or lower stream', () => {
|
||||||
const mediaRow = row({
|
const mediaRow = row({
|
||||||
id: 'media',
|
id: 'media',
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export interface AddMediaFormat {
|
|||||||
isApproximate?: boolean;
|
isApproximate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaType = 'Audio' | 'Video';
|
||||||
|
|
||||||
|
export interface MediaSelection {
|
||||||
|
mediaType: MediaType;
|
||||||
|
format: string;
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddDownloadDraftRow {
|
export interface AddDownloadDraftRow {
|
||||||
id: string;
|
id: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
@@ -353,6 +361,14 @@ export const mediaQualityForFormat = (
|
|||||||
return label || normalizeMediaQualityLabel(format.type) || normalizeMediaQualityLabel(format.ext.toUpperCase()) || 'Media';
|
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 = (
|
export const mediaQualityForRow = (
|
||||||
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
|
row: Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats' | 'selectedFormat'>
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
@@ -362,19 +378,47 @@ export const mediaQualityForRow = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const commonMediaQualitiesForRows = (
|
export const commonMediaQualitiesForRows = (
|
||||||
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>
|
rows: ReadonlyArray<Pick<AddDownloadDraftRow, 'isMedia' | 'status' | 'formats'>>,
|
||||||
|
mediaType?: MediaType,
|
||||||
|
mediaFormat?: string
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const readyMediaRows = rows.filter(row => row.isMedia && row.status === 'ready' && row.formats?.length);
|
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(
|
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 =>
|
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 = (
|
export const selectExactMediaQuality = (
|
||||||
rows: AddDownloadDraftRow[],
|
rows: AddDownloadDraftRow[],
|
||||||
selectedIds: ReadonlySet<string> | readonly string[],
|
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 = (
|
export const mediaFileNameForSelectedFormat = (
|
||||||
fileName: string,
|
fileName: string,
|
||||||
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
row: Pick<AddDownloadDraftRow, 'formats' | 'selectedFormat'>
|
||||||
|
|||||||
Reference in New Issue
Block a user