diff --git a/clis/youtube/channel.js b/clis/youtube/channel.js index b1bd84add..5dab16a74 100644 --- a/clis/youtube/channel.js +++ b/clis/youtube/channel.js @@ -18,6 +18,52 @@ export function extractSelectedRichGridContents(browseData) { return Array.isArray(fallbackContents) ? fallbackContents : []; } +export function parseVideoItem(item) { + const content = item?.richItemRenderer?.content || item || {}; + const normalizeId = (value) => { + const id = String(value || '').trim(); + return /^[A-Za-z0-9_-]+$/.test(id) ? id : ''; + }; + // New lockupViewModel format + const lvm = content.lockupViewModel; + if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO') { + const id = normalizeId(lvm.contentId); + const meta = lvm.metadata?.lockupMetadataViewModel; + const title = meta?.title?.content || ''; + if (!id || !title) + return null; + const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || []; + const parts = (rows[0]?.metadataParts || []).map(p => p.text?.content).filter(Boolean); + let duration = ''; + for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) { + for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) { + if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text; + } + } + return { + title, + duration, + views: parts.join(' | '), + url: 'https://www.youtube.com/watch?v=' + id, + }; + } + // Legacy videoRenderer format + const v = content.videoRenderer || content.gridVideoRenderer; + if (v) { + const id = normalizeId(v.videoId); + const title = v.title?.runs?.[0]?.text || v.title?.simpleText || ''; + if (!id || !title) + return null; + return { + title, + duration: v.lengthText?.simpleText || v.thumbnailOverlays?.find(o => o.thumbnailOverlayTimeStatusRenderer)?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || '', + views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''), + url: 'https://www.youtube.com/watch?v=' + id, + }; + } + return null; +} + cli({ site: 'youtube', name: 'channel', @@ -44,6 +90,7 @@ cli({ const context = cfg.INNERTUBE_CONTEXT; if (!apiKey || !context) return {error: 'YouTube config not found'}; const extractSelectedRichGridContents = ${extractSelectedRichGridContents.toString()}; + const parseVideoItem = ${parseVideoItem.toString()}; // Resolve handle to browseId if needed let browseId = channelId; @@ -98,34 +145,9 @@ cli({ for (const section of sections) { for (const shelf of (section.itemSectionRenderer?.contents || [])) { for (const item of (shelf.shelfRenderer?.content?.horizontalListRenderer?.items || [])) { - // New lockupViewModel format - const lvm = item.lockupViewModel; - if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' && recentVideos.length < limit) { - const meta = lvm.metadata?.lockupMetadataViewModel; - const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || []; - const viewsAndTime = (rows[0]?.metadataParts || []).map(p => p.text?.content).filter(Boolean).join(' | '); - let duration = ''; - for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) { - for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) { - if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text; - } - } - recentVideos.push({ - title: meta?.title?.content || '', - duration, - views: viewsAndTime, - url: 'https://www.youtube.com/watch?v=' + lvm.contentId, - }); - } - // Legacy gridVideoRenderer format - if (item.gridVideoRenderer && recentVideos.length < limit) { - const v = item.gridVideoRenderer; - recentVideos.push({ - title: v.title?.runs?.[0]?.text || v.title?.simpleText || '', - duration: v.thumbnailOverlays?.[0]?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || '', - views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''), - url: 'https://www.youtube.com/watch?v=' + v.videoId, - }); + if (recentVideos.length < limit) { + const parsed = parseVideoItem(item); + if (parsed) recentVideos.push(parsed); } } } @@ -156,14 +178,9 @@ cli({ const richGrid = extractSelectedRichGridContents(videosData); for (const item of richGrid) { if (recentVideos.length >= limit) break; - const v = item.richItemRenderer?.content?.videoRenderer; - if (v) { - recentVideos.push({ - title: v.title?.runs?.[0]?.text || '', - duration: v.lengthText?.simpleText || '', - views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''), - url: 'https://www.youtube.com/watch?v=' + v.videoId, - }); + const parsed = parseVideoItem(item); + if (parsed) { + recentVideos.push(parsed); } } } @@ -206,4 +223,5 @@ cli({ export const __test__ = { extractSelectedRichGridContents, + parseVideoItem, }; diff --git a/clis/youtube/channel.test.js b/clis/youtube/channel.test.js index f8536aba5..99c5c8a22 100644 --- a/clis/youtube/channel.test.js +++ b/clis/youtube/channel.test.js @@ -57,3 +57,227 @@ describe('youtube channel helpers', () => { ]))).toEqual(videos); }); }); + +describe('parseVideoItem', () => { + it('parses lockupViewModel format', () => { + const item = { + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + contentId: 'abc123', + metadata: { + lockupMetadataViewModel: { + title: { content: 'Test Video' }, + metadata: { + contentMetadataViewModel: { + metadataRows: [ + { metadataParts: [{ text: { content: '10K views' } }, { text: { content: '2 days ago' } }] }, + ], + }, + }, + }, + }, + contentImage: { + thumbnailViewModel: { + overlays: [ + { thumbnailBottomOverlayViewModel: { badges: [{ thumbnailBadgeViewModel: { text: '12:34' } }] } }, + ], + }, + }, + }, + }, + }, + }; + const result = __test__.parseVideoItem(item); + expect(result).toEqual({ + title: 'Test Video', + duration: '12:34', + views: '10K views | 2 days ago', + url: 'https://www.youtube.com/watch?v=abc123', + }); + }); + + it('parses legacy videoRenderer format', () => { + const item = { + richItemRenderer: { + content: { + videoRenderer: { + videoId: 'xyz789', + title: { runs: [{ text: 'Legacy Video' }] }, + lengthText: { simpleText: '5:00' }, + shortViewCountText: { simpleText: '1K views' }, + publishedTimeText: { simpleText: '3 days ago' }, + }, + }, + }, + }; + const result = __test__.parseVideoItem(item); + expect(result).toEqual({ + title: 'Legacy Video', + duration: '5:00', + views: '1K views | 3 days ago', + url: 'https://www.youtube.com/watch?v=xyz789', + }); + }); + + it('returns null for non-video items', () => { + expect(__test__.parseVideoItem({})).toBeNull(); + expect(__test__.parseVideoItem({ richItemRenderer: { content: {} } })).toBeNull(); + expect(__test__.parseVideoItem({ + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_PLAYLIST', + contentId: 'playlist-id', + metadata: { lockupMetadataViewModel: { title: { content: 'Playlist' } } }, + }, + }, + }, + })).toBeNull(); + }); + + it('requires stable video identity before emitting a watch URL', () => { + expect(__test__.parseVideoItem({ + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + metadata: { lockupMetadataViewModel: { title: { content: 'Missing id' } } }, + }, + }, + }, + })).toBeNull(); + expect(__test__.parseVideoItem({ + richItemRenderer: { + content: { + videoRenderer: { + videoId: 'bad id with spaces', + title: { simpleText: 'Bad id' }, + }, + }, + }, + })).toBeNull(); + expect(__test__.parseVideoItem({ + richItemRenderer: { + content: { + videoRenderer: { + videoId: 'no-title-id', + }, + }, + }, + })).toBeNull(); + }); + + it('handles lockupViewModel without duration overlay', () => { + const item = { + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + contentId: 'nodur', + metadata: { + lockupMetadataViewModel: { + title: { content: 'No Duration' }, + metadata: { contentMetadataViewModel: { metadataRows: [] } }, + }, + }, + contentImage: { thumbnailViewModel: { overlays: [] } }, + }, + }, + }, + }; + const result = __test__.parseVideoItem(item); + expect(result.duration).toBe(''); + expect(result.title).toBe('No Duration'); + }); + + it('parses the Home tab direct lockupViewModel and gridVideoRenderer shapes', () => { + expect(__test__.parseVideoItem({ + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + contentId: 'homeLockup', + metadata: { + lockupMetadataViewModel: { + title: { content: 'Home Lockup' }, + metadata: { + contentMetadataViewModel: { + metadataRows: [ + { metadataParts: [{ text: { content: '7K views' } }, { text: { content: '1 day ago' } }] }, + ], + }, + }, + }, + }, + contentImage: { thumbnailViewModel: { overlays: [] } }, + }, + })).toMatchObject({ + title: 'Home Lockup', + views: '7K views | 1 day ago', + url: 'https://www.youtube.com/watch?v=homeLockup', + }); + expect(__test__.parseVideoItem({ + gridVideoRenderer: { + videoId: 'gridVideo1', + title: { simpleText: 'Grid Video' }, + thumbnailOverlays: [ + { thumbnailOverlayTimeStatusRenderer: { text: { simpleText: '9:10' } } }, + ], + }, + })).toMatchObject({ + title: 'Grid Video', + duration: '9:10', + url: 'https://www.youtube.com/watch?v=gridVideo1', + }); + }); + + it('prefers lockupViewModel over videoRenderer', () => { + const item = { + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + contentId: 'lockup-id', + metadata: { + lockupMetadataViewModel: { + title: { content: 'Lockup Title' }, + metadata: { contentMetadataViewModel: { metadataRows: [] } }, + }, + }, + contentImage: { thumbnailViewModel: { overlays: [] } }, + }, + videoRenderer: { + videoId: 'legacy-id', + title: { runs: [{ text: 'Legacy Title' }] }, + lengthText: { simpleText: '1:00' }, + }, + }, + }, + }; + const result = __test__.parseVideoItem(item); + expect(result.url).toContain('lockup-id'); + expect(result.title).toBe('Lockup Title'); + }); + + it('is self-contained for browser evaluate injection', () => { + const parseVideoItem = Function(`return ${__test__.parseVideoItem.toString()}`)(); + const item = { + richItemRenderer: { + content: { + lockupViewModel: { + contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', + contentId: 'injected', + metadata: { + lockupMetadataViewModel: { + title: { content: 'Injected' }, + metadata: { contentMetadataViewModel: { metadataRows: [] } }, + }, + }, + contentImage: { thumbnailViewModel: { overlays: [] } }, + }, + }, + }, + }; + expect(parseVideoItem(item).url).toContain('injected'); + }); +});