feat(downloads): support YouTube playlists

This commit is contained in:
NimBold
2026-07-16 16:07:00 +03:30
parent f3d0e0be13
commit a136fa832c
8 changed files with 947 additions and 91 deletions
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MediaPlaylistEntry = { id: string, url: string, title: string, playlist_index: number | null, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MediaPlaylistEntry } from "./MediaPlaylistEntry";
export type MediaPlaylistMetadata = { title: string, playlist_id: string | null, entry_count: number | null, skipped_entries: number, truncated: boolean, entries: Array<MediaPlaylistEntry>, };
+176 -36
View File
@@ -7,12 +7,13 @@ import {
type PendingAddRequestContext
} from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath,
@@ -27,6 +28,7 @@ import {
mediaFileNameForSelectedFormat,
mediaFormatSelectorForRow,
metadataSummaryMessage,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
@@ -97,6 +99,9 @@ export const AddDownloadsModal = () => {
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const metadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>());
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
@@ -159,6 +164,8 @@ export const AddDownloadsModal = () => {
if (context?.filename) return context.filename;
return hasExtensionRequestContext ? '' : pendingAddFilename;
};
const requestContextUrlForRow = (row: AddDownloadDraftRow) =>
row.playlistSourceUrl || row.sourceUrl;
const closeModalFromDismissAction = useCallback(() => {
if (isSubmitting || isSubmittingRef.current) return;
@@ -173,6 +180,9 @@ export const AddDownloadsModal = () => {
if (!isAddModalOpen) {
modalSessionRef.current = false;
setUrls('');
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
return;
}
@@ -189,6 +199,10 @@ export const AddDownloadsModal = () => {
setIsSaveLocationManual(false);
setUrls(initialUrls);
setParsedItems([]);
setPlaylistExpansions({});
metadataRequestsRef.current.clear();
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear();
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
@@ -230,6 +244,11 @@ export const AddDownloadsModal = () => {
|| observedRequestVersionRef.current === pendingAddRequestVersion) return;
const observedVersion = observedRequestVersionRef.current;
observedRequestVersionRef.current = pendingAddRequestVersion;
// Playlist membership and entry access can depend on the handoff's
// browser context. Re-discover playlists when a newer extension context
// arrives instead of reusing entries extracted under stale cookies.
setPlaylistExpansions({});
latestPlaylistRequestRef.current.clear();
setUrls(current => appendRequestUrlsAfterVersion(
current,
pendingAddRequestContexts,
@@ -282,6 +301,21 @@ export const AddDownloadsModal = () => {
}, [saveLocation, isAddModalOpen]);
useEffect(() => {
const activeUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
for (const sourceUrl of latestPlaylistRequestRef.current.keys()) {
if (!activeUrls.has(sourceUrl)) {
latestPlaylistRequestRef.current.delete(sourceUrl);
}
}
setPlaylistExpansions(current => {
const retained = Object.fromEntries(
Object.entries(current).filter(([sourceUrl]) => activeUrls.has(sourceUrl))
);
return Object.keys(retained).length === Object.keys(current).length ? current : retained;
});
const forcedMediaUrls = new Set(pendingAddMediaUrls.map(url => {
try {
return new URL(url).href;
@@ -298,37 +332,54 @@ export const AddDownloadsModal = () => {
Object.entries(pendingAddRequestContexts)
.map(([url, context]) => [url, context.version])
);
setParsedItems(current =>
reconcileDownloadRows(
setParsedItems(current => {
const selectedBySourceUrl = Object.fromEntries(
current.map(row => [row.sourceUrl, row.selected !== false])
);
for (const row of current) {
if (row.playlistSourceUrl && !(row.playlistSourceUrl in selectedBySourceUrl)) {
selectedBySourceUrl[row.playlistSourceUrl] = row.selected !== false;
}
}
return reconcileDownloadRows(
urls,
current,
hasExtensionRequestContext ? undefined : pendingAddFilename || undefined,
forcedMediaUrls,
undefined,
requestFilenames,
requestContextVersions
)
);
requestContextVersions,
playlistExpansions,
selectedBySourceUrl
);
});
}, [
urls,
pendingAddFilename,
pendingAddMediaUrls,
pendingAddRequestContexts,
hasExtensionRequestContext
hasExtensionRequestContext,
playlistExpansions
]);
useEffect(() => {
const maxConcurrentMetadataRequests = 4;
for (const row of parsedItems) {
if (row.status !== 'loading') continue;
if (row.status !== 'loading' || row.selected === false) continue;
const requestKey = `${row.id}:${row.generation}`;
if (metadataRequestsRef.current.has(requestKey)) continue;
metadataRequestsRef.current.add(requestKey);
const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current;
if (requestSet.has(requestKey)) continue;
if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) {
break;
}
requestSet.add(requestKey);
void (async () => {
try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
const contextUrl = requestContextUrlForRow(row);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true);
return;
@@ -345,8 +396,8 @@ export const AddDownloadsModal = () => {
}
}
const rowHeaders = headersForRow(row.sourceUrl);
const rowCookies = cookiesForRow(row.sourceUrl);
const rowHeaders = headersForRow(contextUrl);
const rowCookies = cookiesForRow(contextUrl, row.sourceUrl);
const mediaMetadataArgs = {
url: row.sourceUrl,
cookieBrowser: browserArg,
@@ -357,6 +408,25 @@ export const AddDownloadsModal = () => {
cookies: rowCookies || null,
proxy
};
if (row.isPlaylist) {
if (playlistExpansions[row.sourceUrl]) return;
latestPlaylistRequestRef.current.set(row.sourceUrl, requestKey);
const playlistData = await fetchMediaPlaylistMetadataDeduped({
...mediaMetadataArgs,
url: contextUrl
});
if (latestPlaylistRequestRef.current.get(row.sourceUrl) !== requestKey) return;
if (!playlistData.entries.length) {
throw new Error('Playlist contains no downloadable entries');
}
setPlaylistExpansions(current => ({
...current,
[row.sourceUrl]: playlistData
}));
return;
}
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => {
@@ -385,12 +455,15 @@ export const AddDownloadsModal = () => {
currentRow => ({
...currentRow,
downloadUrl: row.sourceUrl,
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
file: canonicalizeDownloadFileName(
`${playlistFilePrefix(row.playlistIndex, row.playlistCount)}${mediaData.title}.${mediaData.formats[0].ext}`
),
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
sizeBytes: mappedFormats[0].bytes || undefined,
status: 'ready',
formats: mappedFormats,
selectedFormat: 0
selectedFormat: 0,
playlistError: undefined
})
));
} else {
@@ -410,8 +483,8 @@ export const AddDownloadsModal = () => {
userAgent: settingsStore.customUserAgent.trim() || null,
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword,
headers: headersForRow(row.sourceUrl) || null,
cookies: cookiesForRow(row.sourceUrl) || null,
headers: headersForRow(contextUrl) || null,
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
@@ -425,8 +498,8 @@ export const AddDownloadsModal = () => {
...currentRow,
downloadUrl: nextDownloadUrl || currentRow.downloadUrl,
file: canonicalizeDownloadFileName(
current.length === 1 && suggestedFilenameForRow(row.sourceUrl)
? suggestedFilenameForRow(row.sourceUrl)
current.length === 1 && suggestedFilenameForRow(contextUrl)
? suggestedFilenameForRow(contextUrl)
: meta.filename
),
size: meta.size_bytes ? meta.size : undefined,
@@ -450,15 +523,26 @@ export const AddDownloadsModal = () => {
sizeBytes: undefined,
status: 'metadata-error',
formats: undefined,
selectedFormat: undefined
selectedFormat: undefined,
playlistError: row.isPlaylist
? (e instanceof Error ? e.message : String(e))
: undefined
})
));
} finally {
metadataRequestsRef.current.delete(requestKey);
requestSet.delete(requestKey);
}
})();
}
}, [keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, useAuth]);
}, [
keychainAccessReady,
keychainPromptDismissed,
parsedItems,
pendingAddFilename,
pendingAddMediaUrls,
playlistExpansions,
useAuth
]);
useEffect(() => {
if (parsedItems.length === 0) {
@@ -522,6 +606,7 @@ export const AddDownloadsModal = () => {
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
for (const [index, item] of parsedItems.entries()) {
if (item.selected === false) continue;
try {
const suggestedLocation = isSaveLocationManual
? finalLocation
@@ -556,6 +641,7 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
if (item.selected === false) continue;
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
@@ -655,7 +741,9 @@ export const AddDownloadsModal = () => {
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = [...parsedItems];
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
item.selected === false ? null : item
);
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (resolutions) {
@@ -792,6 +880,7 @@ export const AddDownloadsModal = () => {
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const contextUrl = requestContextUrlForRow(item);
const category = categoryForFileName(finalFile);
const added = await addDownload({
@@ -804,11 +893,11 @@ export const AddDownloadsModal = () => {
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(item.sourceUrl) || undefined,
headers: headersForRow(contextUrl) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookiesForRow(item.sourceUrl, item.downloadUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination
? finalLocation
@@ -857,6 +946,19 @@ export const AddDownloadsModal = () => {
</div>
);
const toggleRowSelection = (index: number) => {
setParsedItems(items => items.map((item, itemIndex) =>
itemIndex === index ? { ...item, selected: item.selected === false } : item
));
};
const toggleAllRows = () => {
setParsedItems(items => {
const shouldSelect = items.some(item => item.selected === false);
return items.map(item => ({ ...item, selected: shouldSelect }));
});
};
const selectMediaFormat = (index: number) => {
if (selectedItemIndex === null) return;
const selectedItem = parsedItems[selectedItemIndex];
@@ -879,8 +981,10 @@ export const AddDownloadsModal = () => {
));
};
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = parsedItems.some(item =>
const selectedItems = parsedItems.filter(item => item.selected !== false);
const allRowsSelected = parsedItems.length > 0 && selectedItems.length === parsedItems.length;
const requiredBytes = selectedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = selectedItems.some(item =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0
@@ -889,11 +993,16 @@ export const AddDownloadsModal = () => {
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown';
const canSubmit = canSubmitMetadataRows(parsedItems);
const failedMetadataCount = parsedItems.filter(item => item.status === 'metadata-error').length;
const failedMediaMetadataCount = parsedItems.filter(
const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length;
const failedMediaMetadataCount = selectedItems.filter(
item => item.status === 'metadata-error' && item.isMedia
).length;
const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount;
const activePlaylistUrls = new Set(
urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl)
);
const playlistSummaries = Object.entries(playlistExpansions)
.filter(([sourceUrl]) => activePlaylistUrls.has(sourceUrl));
return (
<>
@@ -957,9 +1066,19 @@ export const AddDownloadsModal = () => {
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
{playlistSummaries.map(([sourceUrl, playlist]) => {
const total = playlist.entry_count || playlist.entries.length;
return (
<p key={sourceUrl} className="px-1 text-[11px] text-purple-500 dark:text-purple-400">
Playlist {playlist.title}: {playlist.entries.length}{total > playlist.entries.length ? ` of ${total}` : ''} entries loaded
{playlist.truncated ? ' (safe entry limit reached)' : ''}
{playlist.skipped_entries > 0 ? `; ${playlist.skipped_entries} skipped, unavailable, duplicated, or outside the safe limit` : ''}.
</p>
);
})}
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">
{parsedItems.filter(item => item.status === 'ready').length} ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
{selectedItems.filter(item => item.status === 'ready').length} selected ready, {fallbackMetadataCount} fallback, {failedMediaMetadataCount} media retry
</span>
<button
type="button"
@@ -969,14 +1088,22 @@ export const AddDownloadsModal = () => {
>
<RefreshCw size={12} /> Refresh Metadata
</button>
</div>
<button
type="button"
onClick={toggleAllRows}
disabled={parsedItems.length === 0}
className="add-download-link-button ml-3 text-[11px] font-medium"
>
{allRowsSelected ? 'Clear selection' : 'Select all'}
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
<SummaryBox title="Files" value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
<SummaryBox title="Unknown" value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
@@ -1014,18 +1141,26 @@ export const AddDownloadsModal = () => {
? 'is-selected border'
: 'border border-transparent'
}`}
>
<div className="flex items-center w-full">
>
<div className="flex items-center w-full">
<input
type="checkbox"
checked={item.selected !== false}
onChange={() => toggleRowSelection(i)}
onClick={event => event.stopPropagation()}
aria-label={`Select ${item.file}`}
className="mr-2 shrink-0 accent-purple-500"
/>
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'metadata-error' || item.status === 'invalid' ? 'text-red-500' : item.status === 'loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> Fetching...
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? 'Fetching playlist...' : 'Fetching...'}
</div>
) : (
item.status === 'metadata-error'
? item.isMedia ? 'Metadata failed' : 'Fallback'
? item.isPlaylist ? 'Playlist failed' : item.isMedia ? 'Metadata failed' : 'Fallback'
: item.status === 'invalid'
? 'Invalid'
: 'Ready'
@@ -1054,6 +1189,11 @@ export const AddDownloadsModal = () => {
</div>
<div className="add-download-section-title flex items-center gap-2 mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> Media Format
{parsedItems[selectedItemIndex].playlistSourceUrl && (
<span className="text-[10px] font-normal text-text-muted">
Playlist item {parsedItems[selectedItemIndex].playlistIndex || '?'}
</span>
)}
</div>
{parsedItems[selectedItemIndex].status === 'loading' ? (
+5
View File
@@ -6,6 +6,7 @@ import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { MediaMetadata } from './bindings/MediaMetadata';
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
import type { MetadataResponse } from './bindings/MetadataResponse';
import type { EngineStatusItem } from './bindings/EngineStatusItem';
import type { PostQueueAction } from './bindings/PostQueueAction';
@@ -24,6 +25,10 @@ type CommandMap = {
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaMetadata;
};
fetch_media_playlist_metadata: {
args: { url: string; cookieBrowser: string | null; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MediaPlaylistMetadata;
};
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
+183
View File
@@ -5,6 +5,8 @@ import {
mediaFormatSelectorForRow,
mediaFileNameForSelectedFormat,
metadataSummaryMessage,
isYouTubePlaylistUrl,
playlistFilePrefix,
reconcileDownloadRows,
refreshFailedMetadataRows,
updateRowIfCurrent,
@@ -57,6 +59,168 @@ describe('add download metadata workflow', () => {
expect(rows.map(item => item.status)).toEqual(['loading', 'invalid', 'invalid']);
});
it('recognizes pure YouTube playlist URLs without changing video-plus-playlist behavior', () => {
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://www.youtube.com/playlist/?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://music.youtube.com/playlist?list=PL123')).toBe(true);
expect(isYouTubePlaylistUrl('https://www.youtube.com/watch?v=video&list=PL123')).toBe(false);
expect(isYouTubePlaylistUrl('https://example.com/playlist?list=PL123')).toBe(false);
});
it('keeps a playlist as one loading row until discovery succeeds', () => {
const rows = reconcileDownloadRows(
'https://www.youtube.com/playlist?list=PL123',
[]
);
expect(rows[0]).toMatchObject({
isMedia: true,
isPlaylist: true,
status: 'loading'
});
});
it('expands playlist entries into independently identifiable media rows', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{ [playlistUrl]: 4 },
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 2,
skipped_entries: 1,
truncated: false,
entries: [
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
{ id: 'one-duplicate', url: 'https://www.youtube.com/watch?v=one', title: 'Duplicate', playlist_index: 2 },
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 3 }
]
}
}
);
expect(rows).toHaveLength(2);
expect(rows.map(item => item.sourceUrl)).toEqual([
'https://www.youtube.com/watch?v=one',
'https://www.youtube.com/watch?v=two'
]);
expect(rows[0]).toMatchObject({
file: '001 - First',
isMedia: true,
playlistSourceUrl: playlistUrl,
playlistTitle: 'Example playlist',
playlistIndex: 1,
playlistCount: 2,
requestContextVersion: 4,
status: 'loading'
});
expect(rows[1].file).toBe('003 - Second');
expect(rows.every(item => !item.isPlaylist)).toBe(true);
});
it('uses a stable three-digit playlist prefix and widens it for four-digit lists', () => {
expect(playlistFilePrefix(1, 12)).toBe('001 - ');
expect(playlistFilePrefix(12, 12)).toBe('012 - ');
expect(playlistFilePrefix(1000, 1000)).toBe('1000 - ');
expect(playlistFilePrefix(undefined, 12)).toBe('');
});
it('propagates a playlist selection to entries discovered after the user deselects it', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 1,
skipped_entries: 0,
truncated: false,
entries: [{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 }]
}
},
{ [playlistUrl]: false }
);
expect(rows).toHaveLength(1);
expect(rows[0].selected).toBe(false);
});
it('preserves entry-level selection when expanded rows are recreated', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const expansion = {
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 2,
skipped_entries: 0,
truncated: false,
entries: [
{ id: 'one', url: 'https://www.youtube.com/watch?v=one', title: 'First', playlist_index: 1 },
{ id: 'two', url: 'https://www.youtube.com/watch?v=two', title: 'Second', playlist_index: 2 }
]
}
};
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
expansion,
{
'https://www.youtube.com/watch?v=one': false,
'https://www.youtube.com/watch?v=two': true
}
);
expect(rows.map(item => item.selected)).toEqual([false, true]);
});
it('does not leave a loading playlist row when every entry is already present', () => {
const videoUrl = 'https://www.youtube.com/watch?v=one';
const playlistUrl = 'https://www.youtube.com/playlist?list=PL123';
const rows = reconcileDownloadRows(
`${videoUrl}\n${playlistUrl}`,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Example playlist',
playlist_id: 'PL123',
entry_count: 1,
skipped_entries: 0,
truncated: false,
entries: [{ id: 'one', url: videoUrl, title: 'First', playlist_index: 1 }]
}
}
);
expect(rows).toHaveLength(1);
expect(rows[0].sourceUrl).toBe(videoUrl);
expect(rows.some(item => item.isPlaylist)).toBe(false);
});
it('forces explicit extension media fetches through media metadata for any http page', () => {
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
@@ -244,6 +408,25 @@ describe('add download metadata workflow', () => {
expect(canSubmitMetadataRows([row({ status: 'invalid' })])).toBe(false);
});
it('validates only selected rows and requires at least one selection', () => {
expect(canSubmitMetadataRows([
row({ status: 'loading' }),
row({ id: 'skipped', status: 'invalid', selected: false })
])).toBe(false);
expect(canSubmitMetadataRows([
row({ status: 'ready' }),
row({ id: 'skipped', status: 'invalid', selected: false })
])).toBe(true);
expect(canSubmitMetadataRows([
row({ selected: false }),
row({ id: 'skipped', selected: false })
])).toBe(false);
expect(metadataSummaryMessage([
row({ status: 'metadata-error', selected: false }),
row({ id: 'ready', status: 'ready' })
])).toContain('Ready to add 1 download');
});
it('keeps failed media routing without a format selector', () => {
const failedMedia = row({
status: 'metadata-error',
+135 -18
View File
@@ -3,6 +3,7 @@ import {
fileNameFromUrl,
isMediaUrl
} from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid';
@@ -31,6 +32,14 @@ export interface AddDownloadDraftRow {
resumable?: boolean;
formats?: AddMediaFormat[];
selectedFormat?: number;
isPlaylist?: boolean;
playlistSourceUrl?: string;
playlistTitle?: string;
playlistIndex?: number;
playlistCount?: number;
playlistEntryTitle?: string;
playlistError?: string;
selected?: boolean;
}
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
@@ -39,9 +48,45 @@ type ParsedInput = {
identity: string;
sourceUrl: string;
valid: boolean;
isPlaylist?: boolean;
playlistSourceUrl?: string;
playlistTitle?: string;
playlistIndex?: number;
playlistCount?: number;
playlistEntryTitle?: string;
requestContextVersion?: number;
selected?: boolean;
};
const parseInputLines = (rawText: string): ParsedInput[] => {
export const isYouTubePlaylistUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);
const hostname = url.hostname.toLowerCase();
const isYouTube = hostname === 'youtube.com' || hostname.endsWith('.youtube.com');
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return isYouTube && pathname === '/playlist' && Boolean(url.searchParams.get('list'));
} catch {
return false;
}
};
export const playlistFilePrefix = (
playlistIndex: number | undefined,
playlistCount: number | undefined
): string => {
if (!playlistIndex || playlistIndex < 1) return '';
const width = Math.max(3, String(playlistCount || playlistIndex).length);
return `${String(playlistIndex).padStart(width, '0')} - `;
};
type PlaylistExpansions = Readonly<Record<string, MediaPlaylistMetadata>>;
const parseInputLines = (
rawText: string,
playlistExpansions: PlaylistExpansions,
requestContextVersions: Readonly<Record<string, number>>,
selectedBySourceUrl: Readonly<Record<string, boolean>>
): ParsedInput[] => {
const seen = new Set<string>();
const parsed: ParsedInput[] = [];
@@ -62,7 +107,50 @@ const parseInputLines = (rawText: string): ParsedInput[] => {
const identity = valid ? sourceUrl : `invalid:${line}`;
if (seen.has(identity)) continue;
seen.add(identity);
parsed.push({ identity, sourceUrl, valid });
if (valid && isYouTubePlaylistUrl(sourceUrl)) {
const expansion = playlistExpansions[sourceUrl];
if (expansion) {
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
for (const [position, entry] of expansion.entries.entries()) {
let entryUrl: string;
try {
const parsedEntryUrl = new URL(entry.url);
if (!ALLOWED_SCHEMES.has(parsedEntryUrl.protocol)) continue;
entryUrl = parsedEntryUrl.href;
} catch {
continue;
}
if (seen.has(entryUrl)) continue;
seen.add(entryUrl);
parsed.push({
identity: entryUrl,
sourceUrl: entryUrl,
valid: true,
playlistSourceUrl: sourceUrl,
playlistTitle: expansion.title,
playlistIndex: entry.playlist_index || position + 1,
playlistCount: expansion.entry_count || expansion.entries.length,
playlistEntryTitle: entry.title,
requestContextVersion: requestContextVersions[sourceUrl],
selected: selectedBySourceUrl[entryUrl] ?? playlistSelected
});
}
// The playlist has been successfully discovered even when every
// entry was already represented by another input row. Do not put the
// source playlist back into loading state in that case.
continue;
}
}
parsed.push({
identity,
sourceUrl,
valid,
isPlaylist: valid && isYouTubePlaylistUrl(sourceUrl),
requestContextVersion: valid ? requestContextVersions[sourceUrl] : undefined,
selected: selectedBySourceUrl[sourceUrl] !== false
});
}
return parsed;
@@ -75,20 +163,29 @@ export const reconcileDownloadRows = (
forceMediaUrls: ReadonlySet<string> = new Set(),
createId: () => string = () => crypto.randomUUID(),
requestFilenames: Readonly<Record<string, string>> = {},
requestContextVersions: Readonly<Record<string, number>> = {}
requestContextVersions: Readonly<Record<string, number>> = {},
playlistExpansions: PlaylistExpansions = {},
selectedBySourceUrl: Readonly<Record<string, boolean>> = {}
): AddDownloadDraftRow[] => {
const inputs = parseInputLines(rawText);
const inputs = parseInputLines(
rawText,
playlistExpansions,
requestContextVersions,
selectedBySourceUrl
);
const existing = new Map(currentRows.map(row => [row.sourceUrl, row]));
return inputs.map(input => {
const preserved = existing.get(input.sourceUrl);
if (preserved) {
const forcedMedia = input.valid && forceMediaUrls.has(input.sourceUrl);
const requestContextVersion = requestContextVersions[input.sourceUrl];
const requestContextVersion = input.requestContextVersion;
const contextChanged = requestContextVersion !== undefined
&& requestContextVersion !== preserved.requestContextVersion;
if ((forcedMedia && !preserved.isMedia) || contextChanged) {
const requestedFilename = requestFilenames[input.sourceUrl];
const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl];
return {
...preserved,
file: contextChanged
@@ -99,13 +196,16 @@ export const reconcileDownloadRows = (
requestContextVersion,
isMedia: preserved.isMedia || forcedMedia,
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat,
playlistError: undefined
};
}
return preserved;
}
const requestedFilename = requestFilenames[input.sourceUrl]
const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl]
|| (inputs.length === 1 ? pendingFilename : undefined);
const fallback = canonicalizeDownloadFileName(
requestedFilename || fileNameFromUrl(input.sourceUrl)
@@ -118,8 +218,20 @@ export const reconcileDownloadRows = (
file: fallback,
status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0,
requestContextVersion: requestContextVersions[input.sourceUrl],
isMedia: input.valid && (forceMediaUrls.has(input.sourceUrl) || isMediaUrl(input.sourceUrl))
requestContextVersion: input.requestContextVersion,
isMedia: input.valid && (
Boolean(input.isPlaylist)
|| Boolean(input.playlistSourceUrl)
|| forceMediaUrls.has(input.sourceUrl)
|| isMediaUrl(input.sourceUrl)
),
isPlaylist: input.isPlaylist,
playlistSourceUrl: input.playlistSourceUrl,
playlistTitle: input.playlistTitle,
playlistIndex: input.playlistIndex,
playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle,
selected: input.selected !== false
};
});
};
@@ -177,12 +289,14 @@ export const refreshFailedMetadataRows = (
: row
);
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean =>
rows.length > 0
&& rows.every(row =>
export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => {
const selectedRows = rows.filter(row => row.selected !== false);
return selectedRows.length > 0
&& selectedRows.every(row =>
row.status === 'ready'
|| (!row.isMedia && row.status === 'metadata-error')
);
};
export const mediaFormatSelectorForRow = (
row: AddDownloadDraftRow
@@ -228,19 +342,22 @@ export const mediaFileNameForSelectedFormat = (
export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => {
if (rows.length === 0) return 'Paste one or more links.';
const invalid = rows.filter(row => row.status === 'invalid').length;
const selectedRows = rows.filter(row => row.selected !== false);
if (selectedRows.length === 0) return 'Select at least one download.';
const invalid = selectedRows.filter(row => row.status === 'invalid').length;
if (invalid > 0) {
return `Correct or remove ${invalid} invalid URL${invalid === 1 ? '' : 's'} before continuing.`;
}
const loading = rows.filter(row => row.status === 'loading').length;
const loading = selectedRows.filter(row => row.status === 'loading').length;
if (loading > 0) {
return `Waiting for metadata for ${loading} download${loading === 1 ? '' : 's'}.`;
}
const failed = rows.filter(row => row.status === 'metadata-error').length;
const failedMedia = rows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
const ready = rows.filter(row => row.status === 'ready').length;
const failed = selectedRows.filter(row => row.status === 'metadata-error').length;
const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length;
const ready = selectedRows.filter(row => row.status === 'ready').length;
if (failedMedia > 0) {
return `Media metadata is unavailable for ${failedMedia} item${failedMedia === 1 ? '' : 's'}. Refresh metadata before adding.`;
}
+17
View File
@@ -1,5 +1,6 @@
import { invokeCommand as invoke } from '../ipc';
import type { MediaMetadata } from '../bindings/MediaMetadata';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
type FetchMediaMetadataArgs = {
url: string;
@@ -13,6 +14,7 @@ type FetchMediaMetadataArgs = {
};
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
const inFlightMediaPlaylists = new Map<string, Promise<MediaPlaylistMetadata>>();
const metadataKey = (args: FetchMediaMetadataArgs) =>
JSON.stringify([
@@ -38,3 +40,18 @@ export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise
inFlightMediaMetadata.set(key, request);
return request;
};
export const fetchMediaPlaylistMetadataDeduped = (
args: FetchMediaMetadataArgs
): Promise<MediaPlaylistMetadata> => {
const key = metadataKey(args);
const existing = inFlightMediaPlaylists.get(key);
if (existing) return existing;
const request = invoke('fetch_media_playlist_metadata', args)
.finally(() => {
inFlightMediaPlaylists.delete(key);
});
inFlightMediaPlaylists.set(key, request);
return request;
};