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
+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;
};