playlist.js
1 /** 2 * YouTube playlist — get playlist info and video list via InnerTube browse API. 3 */ 4 import { cli, Strategy } from '@jackwener/opencli/registry'; 5 import { prepareYoutubeApiPage, FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js'; 6 import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; 7 8 /** 9 * Parse a playlist ID from a URL or bare ID string. 10 */ 11 function parsePlaylistId(input) { 12 if (!input.startsWith('http')) 13 return input; 14 try { 15 const url = new URL(input); 16 return url.searchParams.get('list') || input; 17 } 18 catch { 19 return input; 20 } 21 } 22 23 cli({ 24 site: 'youtube', 25 name: 'playlist', 26 description: 'Get YouTube playlist info and video list', 27 domain: 'www.youtube.com', 28 strategy: Strategy.COOKIE, 29 args: [ 30 { name: 'id', required: true, positional: true, help: 'Playlist URL or playlist ID (PLxxxxxx)' }, 31 { name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' }, 32 ], 33 columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'], 34 func: async (page, kwargs) => { 35 const playlistId = parsePlaylistId(String(kwargs.id)); 36 const limit = Math.min(kwargs.limit || 50, 200); 37 await prepareYoutubeApiPage(page); 38 const data = await page.evaluate(` 39 (async () => { 40 const cfg = window.ytcfg?.data_ || {}; 41 const apiKey = cfg.INNERTUBE_API_KEY; 42 const context = cfg.INNERTUBE_CONTEXT; 43 if (!apiKey || !context) return { error: 'YouTube config not found' }; 44 45 const browseId = 'VL' + ${JSON.stringify(playlistId)}; 46 const limit = ${limit}; 47 48 ${FETCH_BROWSE_FN} 49 50 const data = await fetchBrowse(apiKey, { context, browseId }); 51 if (data.error) return data; 52 53 const header = data.header?.pageHeaderRenderer; 54 const title = header?.pageTitle || ''; 55 const metaRows = header?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || []; 56 const stats = metaRows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean)); 57 58 const sidebarItems = data.sidebar?.playlistSidebarRenderer?.items || []; 59 const secondaryInfo = sidebarItems.find(i => i.playlistSidebarSecondaryInfoRenderer)?.playlistSidebarSecondaryInfoRenderer; 60 const channelName = secondaryInfo?.videoOwner?.videoOwnerRenderer?.title?.runs?.[0]?.text || ''; 61 62 const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || []; 63 let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || []; 64 65 const extractVideos = ${extractPlaylistVideos.toString()}; 66 67 let videos = extractVideos(listContents); 68 69 let contItem = listContents[listContents.length - 1]; 70 while (videos.length < limit && contItem?.continuationItemRenderer) { 71 const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token; 72 if (!token) break; 73 const contData = await fetchBrowse(apiKey, { context, continuation: token }); 74 if (contData.error) break; 75 const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || []; 76 if (!newItems.length) break; 77 videos = videos.concat(extractVideos(newItems)); 78 contItem = newItems[newItems.length - 1]; 79 } 80 81 return { title, channelName, stats, videos: videos.slice(0, limit) }; 82 })() 83 `); 84 if (!data || typeof data !== 'object') { 85 throw new CommandExecutionError('Failed to fetch playlist data'); 86 } 87 if (data.error) { 88 throw new CommandExecutionError(String(data.error)); 89 } 90 if (!data.videos?.length) { 91 throw new EmptyResultError('youtube playlist'); 92 } 93 const statsStr = (data.stats || []).join(' | '); 94 process.stderr.write(`${data.title} [${data.channelName}] ${statsStr}\n`); 95 return data.videos; 96 }, 97 });