diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index d03530d..e2c0378 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -33,17 +33,22 @@ import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/ import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, + commonMediaFormatsForRows, commonMediaQualitiesForRows, mediaFileNameForSelectedFormat, + mediaFormatForFormat, + mediaQualityForFormat, mediaQualityForRow, + mediaTypeForFormat, mediaFormatSelectorForRow, metadataSummaryState, playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, - selectExactMediaQuality, + selectExactMediaSelection, updateRowIfCurrent, - type AddDownloadDraftRow + type AddDownloadDraftRow, + type MediaSelection } from '../utils/addDownloadMetadata'; const formatBytes = (bytes: number) => { @@ -189,6 +194,9 @@ export const AddDownloadsModal = () => { const [password, setPassword] = useState(''); const [advancedExpanded, setAdvancedExpanded] = useState(false); + const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true); + const playlistMediaSelectionsRef = useRef(new Map()); + const [playlistMediaTypeSelections, setPlaylistMediaTypeSelections] = useState>({}); const [checksumEnabled, setChecksumEnabled] = useState(false); const [checksumAlgo, setChecksumAlgo] = useState('SHA-256'); const [checksumValue, setChecksumValue] = useState(''); @@ -248,6 +256,9 @@ export const AddDownloadsModal = () => { setPlaylistExpansions({}); playlistRequestsRef.current.clear(); latestPlaylistRequestRef.current.clear(); + playlistMediaSelectionsRef.current.clear(); + setPlaylistMediaTypeSelections({}); + setPlaylistQualityExpanded(true); return; } @@ -276,6 +287,9 @@ export const AddDownloadsModal = () => { metadataRequestsRef.current.clear(); playlistRequestsRef.current.clear(); latestPlaylistRequestRef.current.clear(); + playlistMediaSelectionsRef.current.clear(); + setPlaylistMediaTypeSelections({}); + setPlaylistQualityExpanded(true); setSelectedItemIndex(null); setPendingUseSharedDestination(false); setPendingDestinationOverrides({}); @@ -325,6 +339,8 @@ export const AddDownloadsModal = () => { // arrives instead of reusing entries extracted under stale cookies. setPlaylistExpansions({}); latestPlaylistRequestRef.current.clear(); + playlistMediaSelectionsRef.current.clear(); + setPlaylistMediaTypeSelections({}); setUrls(current => appendRequestUrlsAfterVersion( current, pendingAddRequestContexts, @@ -386,6 +402,17 @@ export const AddDownloadsModal = () => { 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 => { const retained = Object.fromEntries( Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl)) @@ -532,6 +559,26 @@ export const AddDownloadsModal = () => { 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( current, row.id, @@ -541,13 +588,13 @@ export const AddDownloadsModal = () => { ...currentRow, downloadUrl: row.sourceUrl, 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, - sizeBytes: mappedFormats[0].bytes || undefined, + size: selectedFormat.bytes ? selectedFormat.detail : undefined, + sizeBytes: selectedFormat.bytes || undefined, status: 'ready', formats: mappedFormats, - selectedFormat: 0, + selectedFormat: selectedFormatIndex, 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) => { if (selectedItemIndex === null) return; const selectedItem = parsedItems[selectedItemIndex]; const format = selectedItem?.formats?.[index]; if (!selectedItem || !format) return; + clearPlaylistMediaSelection(selectedItem.playlistSourceUrl); setParsedItems(items => items.map((item, itemIndex) => itemIndex === selectedItemIndex @@ -1290,13 +1349,119 @@ export const AddDownloadsModal = () => { }; 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) ); - const bulkQualityOptions = commonMediaQualitiesForRows(selectedReadyMediaItems); - const applyBulkMediaQuality = (quality: string) => { - const selectedIds = selectedReadyMediaItems.map(item => item.id); - setParsedItems(items => selectExactMediaQuality(items, selectedIds, quality)); + const selectedPlaylistFormatObject = selectedItem?.formats?.[selectedItem.selectedFormat ?? -1] + || selectedPlaylistReadyRows[0]?.formats?.[selectedPlaylistReadyRows[0].selectedFormat ?? -1]; + const detectedPlaylistMediaType = selectedPlaylistFormatObject + ? 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 requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); @@ -1574,34 +1739,6 @@ export const AddDownloadsModal = () => {
- {selectedReadyMediaItems.length > 1 ? ( -
-
- - {bulkQualityOptions.length > 0 ? ( -
$.addDownloads.applyQualityToSelected)}> - {bulkQualityOptions.map(quality => ( - - ))} -
- ) : ( - - {t($ => $.addDownloads.noCommonQuality)} - - )} -
-
- ) : null} - {/* Media Format (Dynamic) */} {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
@@ -1660,12 +1797,131 @@ export const AddDownloadsModal = () => { })}
- + ) : (
{t($ => $.addDownloads.metadataUnavailable)}
)} + + {selectedPlaylistSourceUrl && selectedPlaylistReadyRows.length > 0 ? ( +
+ + + {playlistQualityExpanded ? ( + hasPlaylistFormatOptions ? ( + <> + {playlistVideoFormatOptions.length > 0 && playlistAudioFormatOptions.length > 0 ? ( +
$.addDownloads.mediaFormat)}> + + +
+ ) : null} + +
+
+
{t($ => $.addDownloads.quality)}
+ {playlistQualityOptions.length > 0 ? ( +
$.addDownloads.quality)}> + {playlistQualityOptions.map(quality => { + const isApplied = appliedPlaylistSelection?.mediaType === selectedPlaylistMediaType + && appliedPlaylistSelection.format === selectedPlaylistFormat + && appliedPlaylistSelection.quality === quality; + return ( + + ); + })} +
+ ) : ( + + {t($ => $.addDownloads.noCommonQuality)} + + )} +
+ + {playlistFormatOptions.length > 0 ? ( +
+
{t($ => $.addDownloads.format)}
+
$.addDownloads.format)}> + {playlistFormatOptions.map(format => { + const isApplied = appliedPlaylistFormat === format; + return ( + + ); + })} +
+
+ ) : null} +
+ + ) : ( +
+ {t($ => $.addDownloads.noCommonFormat)} +
+ ) + ) : null} +
+ ) : null} )} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index a515a54..40c7818 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -472,8 +472,13 @@ const common = { availableStreams: 'Available Streams', availableMediaStreams: 'Available media streams', quality: 'Quality', - applyQualityToSelected: 'Apply exact quality to selected media', - noCommonQuality: 'No exact quality is available for every selected media item.', + format: 'Format', + 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.', saveLocation: 'Save Location', browse: 'Browse', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 8a91e51..5aea08b 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -472,8 +472,13 @@ const fa = { availableStreams: 'جریان‌های موجود', availableMediaStreams: 'جریان‌های رسانه موجود', quality: 'کیفیت', - applyQualityToSelected: 'اعمال کیفیت دقیق برای رسانه‌های انتخاب‌شده', - noCommonQuality: 'کیفیت دقیقی برای همه رسانه‌های انتخاب‌شده وجود ندارد.', + format: 'قالب', + video: 'ویدئو', + audio: 'صوتی', + noCommonFormat: 'هیچ فرمت مشترکی برای همه موارد انتخاب‌شده وجود ندارد.', + noCommonQuality: 'کیفیت یکسانی برای همه موارد انتخاب‌شده وجود ندارد.', + playlistQuality: 'کیفیت فهرست پخش', + playlistQualitySelected: '{{selected}} از {{total}} مورد انتخاب شده', metadataUnavailable: 'متادیتا در دسترس نیست. پیش از افزودن این رسانه، متادیتا را تازه‌سازی کنید.', saveLocation: 'محل ذخیره', browse: 'انتخاب…', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index d3abc97..fa4ed05 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -472,8 +472,13 @@ const he = { availableStreams: 'זרמים זמינים', availableMediaStreams: 'זרמי מדיה זמינים', quality: 'איכות', - applyQualityToSelected: 'החלת איכות מדויקת על המדיה שנבחרה', - noCommonQuality: 'אין איכות מדויקת הזמינה לכל פריטי המדיה שנבחרו.', + format: 'פורמט', + video: 'וידאו', + audio: 'שמע', + noCommonFormat: 'אין פורמט אחד זמין לכל הפריטים שנבחרו.', + noCommonQuality: 'אין איכות אחת זמינה לכל הפריטים שנבחרו.', + playlistQuality: 'איכות רשימת השמעה', + playlistQualitySelected: '{{selected}} מתוך {{total}} נבחרו', metadataUnavailable: 'מטא נתונים אינם זמינים. רענן מטא נתונים לפני הוספת מדיה זו.', saveLocation: 'מיקום שמירה', browse: 'עיון…', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 32e5d13..8e7d27a 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -472,8 +472,13 @@ const ru = { availableStreams: 'Доступные потоки', availableMediaStreams: 'Доступные медиапотоки', quality: 'Качество', - applyQualityToSelected: 'Применить точное качество к выбранным медиа', - noCommonQuality: 'Для всех выбранных медиа нет одинакового качества.', + format: 'Формат', + video: 'Видео', + audio: 'Аудио', + noCommonFormat: 'Для всех выбранных элементов нет общего формата.', + noCommonQuality: 'Для всех выбранных элементов нет общего качества.', + playlistQuality: 'Качество плейлиста', + playlistQualitySelected: 'Выбрано: {{selected}} из {{total}}', metadataUnavailable: 'Метаданные недоступны. Обновите метаданные перед добавлением этого медиа.', saveLocation: 'Папка сохранения', browse: 'Обзор…', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index e1dc836..d308be4 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -472,8 +472,13 @@ const uk = { availableStreams: 'Доступні потоки', availableMediaStreams: 'Доступні медіапотоки', quality: 'Якість', - applyQualityToSelected: 'Застосувати точну якість до вибраних медіа', - noCommonQuality: 'Для всіх вибраних медіа немає однакової якості.', + format: 'Формат', + video: 'Відео', + audio: 'Аудіо', + noCommonFormat: 'Немає спільного формату для всіх вибраних елементів.', + noCommonQuality: 'Немає спільної якості для всіх вибраних елементів.', + playlistQuality: 'Якість плейлиста', + playlistQualitySelected: 'Вибрано: {{selected}} із {{total}}', metadataUnavailable: 'Метадані недоступні. Оновіть метадані перед додаванням цього медіа.', saveLocation: 'Місце збереження', browse: 'Огляд…', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index e02ee76..a598ef6 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -472,8 +472,13 @@ const zhCN = { availableStreams: '可用流', availableMediaStreams: '可用的媒体流', quality: '画质', - applyQualityToSelected: '将精确画质应用到选中的媒体', - noCommonQuality: '所选媒体没有共同的精确画质。', + format: '格式', + video: '视频', + audio: '音频', + noCommonFormat: '所选项目没有共同格式。', + noCommonQuality: '所选项目没有共同画质。', + playlistQuality: '播放列表画质', + playlistQualitySelected: '已选择 {{selected}} / {{total}}', metadataUnavailable: '元数据不可用。在添加此媒体之前,请刷新元数据。', saveLocation: '保存位置', browse: '浏览', diff --git a/src/index.css b/src/index.css index 75d5b8f..3bf8a16 100644 --- a/src/index.css +++ b/src/index.css @@ -794,6 +794,147 @@ html[data-list-density="relaxed"] { 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, .download-quality-chip { display: inline-flex; @@ -810,17 +951,6 @@ html[data-list-density="relaxed"] { 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 { appearance: auto; cursor: default; diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index acf4557..b9ac0a2 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -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', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index e97e422..fa34980 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -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 +): MediaType => format.type.toLowerCase().includes('audio') ? 'Audio' : 'Video'; + +export const mediaFormatForFormat = ( + format: Pick +): string => normalizeMediaQualityLabel(format.ext.replace(/^\.+/, '').toUpperCase()) || 'MEDIA'; + export const mediaQualityForRow = ( row: Pick ): string | undefined => { @@ -362,19 +378,47 @@ export const mediaQualityForRow = ( }; export const commonMediaQualitiesForRows = ( - rows: ReadonlyArray> + rows: ReadonlyArray>, + 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>, + 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 | readonly string[], @@ -399,6 +443,34 @@ export const selectExactMediaQuality = ( }); }; +export const selectExactMediaSelection = ( + rows: AddDownloadDraftRow[], + selectedIds: ReadonlySet | 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