Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 54 additions & 36 deletions clis/youtube/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -206,4 +223,5 @@ cli({

export const __test__ = {
extractSelectedRichGridContents,
parseVideoItem,
};
224 changes: 224 additions & 0 deletions clis/youtube/channel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});