import { Buffer } from 'node:buffer'; /** * A lightweight utility to recreate Invidious's YouTube channel video listing and pagination logic. * It builds synthetic continuation tokens and fetches videos directly using the InnerTube API. */ // Helper to write unsigned varints function writeVarint(val: number | bigint): Uint8Array { const bytes: number[] = []; let n = BigInt(val); while (n > 127n) { bytes.push(Number((n & 0x7fn) | 0x80n)); n >>= 7n; } bytes.push(Number(n)); return new Uint8Array(bytes); } // Concatenate multiple Uint8Arrays function concat(...arrays: Uint8Array[]): Uint8Array { let totalLength = 0; for (const arr of arrays) { totalLength += arr.length; } const result = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } /** * Serializes a JS representation of nested protobuf messages into raw bytes. * The input object should have keys formatted as "fieldNumber:wireType" * e.g., "15:embedded", "4:varint", "1:string". */ export function serializeProto(obj: any): Uint8Array { const chunks: Uint8Array[] = []; for (const [key, val] of Object.entries(obj)) { if (val === undefined || val === null) { continue; } const parts = key.split(':'); const fieldNum = parseInt(parts[0], 10); const type = parts[1]; if (isNaN(fieldNum)) { continue; } if (type === 'varint') { const tag = (fieldNum << 3) | 0; const tagBytes = writeVarint(tag); const valBytes = writeVarint(typeof val === 'boolean' ? (val ? 1 : 0) : (val as number | bigint)); chunks.push(concat(tagBytes, valBytes)); } else if (type === 'string') { const tag = (fieldNum << 3) | 2; const tagBytes = writeVarint(tag); const strBytes = typeof val === 'string' ? new TextEncoder().encode(val) : (val as Uint8Array); const lenBytes = writeVarint(strBytes.length); chunks.push(concat(tagBytes, lenBytes, strBytes)); } else if (type === 'embedded') { const tag = (fieldNum << 3) | 2; const tagBytes = writeVarint(tag); const subBytes = serializeProto(val); const lenBytes = writeVarint(subBytes.length); chunks.push(concat(tagBytes, lenBytes, subBytes)); } } return concat(...chunks); } function sortOptionsVideosShort(sortBy: string): number { switch (sortBy) { case 'newest': return 4; case 'popular': return 2; case 'oldest': return 5; default: return 4; } } /** * Wraps a tab-specific object into the outer continuation token structure */ function channelCtokenWrap(ucid: string, object: any): string { const objectInner = { "110:embedded": { "3:embedded": object } }; const innerSerialized = serializeProto(objectInner); const objectInnerEncoded = Buffer.from(innerSerialized).toString('base64url'); const outerObject = { "80226972:embedded": { "2:string": ucid, "3:string": objectInnerEncoded } }; const outerSerialized = serializeProto(outerObject); return Buffer.from(outerSerialized).toString('base64url'); } /** * Generates the initial continuation token for the Videos tab */ export function makeInitialVideosCtoken(ucid: string, sortBy = 'newest'): string { const sortVal = sortOptionsVideosShort(sortBy); const object = { "15:embedded": { "2:embedded": { "1:string": "00000000-0000-0000-0000-000000000000" }, "4:varint": sortVal, "8:embedded": { "1:string": "00000000-0000-0000-0000-000000000000", "3:varint": sortVal } } }; return channelCtokenWrap(ucid, object); } /** * Generates the initial continuation token for the Shorts tab */ export function makeInitialShortsCtoken(ucid: string, sortBy = 'newest'): string { const sortVal = sortOptionsVideosShort(sortBy); const object = { "10:embedded": { "2:embedded": { "1:string": "00000000-0000-0000-0000-000000000000" }, "4:varint": sortVal, "7:embedded": { "1:string": "00000000-0000-0000-0000-000000000000", "3:varint": sortVal } } }; return channelCtokenWrap(ucid, object); } /** * Generates the initial continuation token for the Livestreams tab */ export function makeInitialLivestreamsCtoken(ucid: string, sortBy = 'newest'): string { let sortVal = 12; switch (sortBy) { case 'newest': sortVal = 12; break; case 'popular': sortVal = 14; break; case 'oldest': sortVal = 13; break; } const object = { "14:embedded": { "2:embedded": { "1:string": "00000000-0000-0000-0000-000000000000" }, "5:varint": sortVal, "8:embedded": { "1:string": "00000000-0000-0000-0000-000000000000", "3:varint": sortVal } } }; return channelCtokenWrap(ucid, object); } function parseText(node: any): string { if (!node) return ''; if (typeof node.simpleText === 'string') { return node.simpleText; } if (Array.isArray(node.runs)) { return node.runs.map((run: any) => run.text || '').join(''); } return ''; } function decodeLengthSeconds(lengthText: string): number { if (!lengthText) return 0; const parts = lengthText.split(':').map(Number); if (parts.some(isNaN)) return 0; let seconds = 0; if (parts.length === 2) { seconds = parts[0] * 60 + parts[1]; } else if (parts.length === 3) { seconds = parts[0] * 3600 + parts[1] * 60 + parts[2]; } return seconds; } function parseVideoRenderer(renderer: any) { const videoId = renderer.videoId || ''; const title = parseText(renderer.title); let author = ''; let authorId = ''; const ownerText = renderer.ownerText || renderer.shortBylineText; if (ownerText && Array.isArray(ownerText.runs) && ownerText.runs[0]) { author = ownerText.runs[0].text || ''; authorId = ownerText.runs[0].navigationEndpoint?.browseEndpoint?.browseId || ''; } const publishedText = parseText(renderer.publishedTimeText); let viewCountText = parseText(renderer.viewCountText); if (!viewCountText && renderer.shortViewCountText) { viewCountText = parseText(renderer.shortViewCountText); } let lengthSeconds = 0; if (renderer.lengthText) { lengthSeconds = decodeLengthSeconds(parseText(renderer.lengthText)); } else if (Array.isArray(renderer.thumbnailOverlays)) { const timeOverlay = renderer.thumbnailOverlays.find((overlay: any) => overlay.thumbnailOverlayTimeStatusRenderer); if (timeOverlay) { const timeText = parseText(timeOverlay.thumbnailOverlayTimeStatusRenderer.text); if (timeText === 'SHORTS') { lengthSeconds = 60; } else { lengthSeconds = decodeLengthSeconds(timeText); } } } let liveNow = false; if (Array.isArray(renderer.badges)) { liveNow = renderer.badges.some((badge: any) => badge.metadataBadgeRenderer?.label === 'LIVE' ); } return { id: videoId, title, author, authorId, publishedText, viewCountText, lengthSeconds, liveNow, isPlaylist: false }; } function parseShortsLockup(model: any) { const videoId = model.onTap?.innertubeCommand?.reelWatchEndpoint?.videoId || ''; const title = model.overlayMetadata?.primaryText?.content || ''; const viewCountText = model.overlayMetadata?.secondaryText?.content || ''; return { id: videoId, title, author: '', authorId: '', publishedText: '', viewCountText, lengthSeconds: 60, liveNow: false, isPlaylist: false }; } function parseLockupViewModel(model: any) { const isVideo = model.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO'; const id = model.contentId || model.onTap?.innertubeCommand?.watchEndpoint?.videoId || ''; const title = model.metadata?.lockupMetadataViewModel?.title?.content || ''; let viewCountText = ''; let publishedText = ''; const metadataRows = model.metadata?.lockupMetadataViewModel?.metadata?.contentMetadataViewModel?.metadataRows; if (Array.isArray(metadataRows) && metadataRows[0]) { const parts = metadataRows[0].metadataParts; if (Array.isArray(parts)) { if (parts[0]?.text?.content) { viewCountText = parts[0].text.content; } if (parts[1]?.text?.content) { publishedText = parts[1].text.content; } } } let lengthSeconds = 0; const overlays = model.contentImage?.thumbnailViewModel?.overlays; if (Array.isArray(overlays)) { const timeOverlay = overlays.find((overlay: any) => overlay.thumbnailOverlayTimeStatusRenderer); if (timeOverlay) { const timeText = timeOverlay.thumbnailOverlayTimeStatusRenderer.text?.content || ''; if (timeText === 'SHORTS') { lengthSeconds = 60; } else { lengthSeconds = decodeLengthSeconds(timeText); } } else { const bottomOverlay = overlays.find((overlay: any) => overlay.thumbnailBottomOverlayViewModel); if (bottomOverlay && Array.isArray(bottomOverlay.thumbnailBottomOverlayViewModel.badges)) { const badge = bottomOverlay.thumbnailBottomOverlayViewModel.badges.find((b: any) => b.thumbnailBadgeViewModel); if (badge) { const timeText = badge.thumbnailBadgeViewModel.text || ''; if (timeText === 'SHORTS') { lengthSeconds = 60; } else { lengthSeconds = decodeLengthSeconds(timeText); } } } } } return { id, title, author: '', authorId: '', publishedText, viewCountText, lengthSeconds, liveNow: false, isPlaylist: !isVideo }; } // Tree walker to find video/playlist renderers and continuation tokens recursively function findRenderers(obj: any, results: { videos: any[]; continuationToken: string | null }) { if (!obj || typeof obj !== 'object') { return; } if (Array.isArray(obj)) { for (const item of obj) { findRenderers(item, results); } return; } // Check for continuation token if (obj.continuationItemRenderer) { const token = obj.continuationItemRenderer.continuationEndpoint?.continuationCommand?.token; if (token) { results.continuationToken = token; } return; } // Check for video renderer if (obj.videoRenderer || obj.gridVideoRenderer) { const renderer = obj.videoRenderer || obj.gridVideoRenderer; results.videos.push(parseVideoRenderer(renderer)); return; } // Check for shorts/lockup view models if (obj.shortsLockupViewModel) { results.videos.push(parseShortsLockup(obj.shortsLockupViewModel)); return; } if (obj.lockupViewModel) { results.videos.push(parseLockupViewModel(obj.lockupViewModel)); return; } for (const key of Object.keys(obj)) { findRenderers(obj[key], results); } } /** * Parses the raw InnerTube response to extract video objects and continuation token. */ export function parseBrowseResponse(data: any): { videos: any[]; continuation: string | null } { const results: { videos: any[]; continuationToken: string | null } = { videos: [], continuationToken: null }; findRenderers(data, results); return { videos: results.videos, continuation: results.continuationToken }; } /** * Fetches videos from a channel using Invidious ctoken generation and browse endpoint. */ export async function fetchChannelVideos( ucid: string, sortBy: 'newest' | 'popular' | 'oldest' = 'newest', continuation?: string ): Promise<{ videos: any[]; continuation: string | null }> { const token = continuation || makeInitialVideosCtoken(ucid, sortBy); const payload = { context: { client: { hl: 'en', gl: 'US', clientName: 'WEB', clientVersion: '2.20250222.10.00' } }, continuation: token }; const headers = { 'Content-Type': 'application/json', 'x-youtube-client-name': '1', 'x-youtube-client-version': '2.20250222.10.00', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' }; const response = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', { method: 'POST', headers, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`YouTube API returned HTTP ${response.status}`); } const data = await response.json() as any; if (data.error) { throw new Error(`YouTube API error ${data.error.code}: ${data.error.message}`); } return parseBrowseResponse(data); } export function getVideoThumbnails(videoId: string) { return [ { quality: "default", url: `https://i.ytimg.com/vi/${videoId}/default.jpg`, width: 120, height: 90 }, { quality: "medium", url: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 }, { quality: "high", url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 }, { quality: "standard", url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, { quality: "sddefault", url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 }, { quality: "maxres", url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 }, { quality: "maxresdefault", url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 } ]; } export function parseViewCount(text: string): number { if (!text) return 0; const match = text.replace(/,/g, '').match(/\d+/); if (match && match[0]) { return parseInt(match[0], 10); } const clean = text.toLowerCase(); if (clean.includes('k')) { const num = parseFloat(clean.split('k')[0] || '0'); return isNaN(num) ? 0 : Math.floor(num * 1000); } if (clean.includes('m')) { const num = parseFloat(clean.split('m')[0] || '0'); return isNaN(num) ? 0 : Math.floor(num * 1000000); } return 0; } export function relativeTimeToTimestamp(publishedText: string): number { if (!publishedText) return 0; const now = Math.floor(Date.now() / 1000); const match = publishedText.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/i); if (!match || !match[1] || !match[2]) return now; const value = parseInt(match[1], 10); const unit = match[2].toLowerCase(); let diff = 0; switch (unit) { case 'second': diff = value; break; case 'minute': diff = value * 60; break; case 'hour': diff = value * 3600; break; case 'day': diff = value * 86400; break; case 'week': diff = value * 604800; break; case 'month': diff = value * 2592000; break; case 'year': diff = value * 31536000; break; } return (now - diff) * 1000; } export async function fetchChannelInfo(channelId: string) { const payload = { context: { client: { hl: 'en', gl: 'US', clientName: 'WEB', clientVersion: '2.20250222.10.00' } }, browseId: channelId }; const headers = { 'Content-Type': 'application/json', 'x-youtube-client-name': '1', 'x-youtube-client-version': '2.20250222.10.00', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36' }; const response = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', { method: 'POST', headers, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`YouTube API returned HTTP ${response.status}`); } const data = await response.json() as any; if (data.error) { throw new Error(`YouTube API error ${data.error.code}: ${data.error.message}`); } const renderer = data.metadata?.channelMetadataRenderer; if (!renderer) { throw new Error('Could not find channel metadata renderer in YouTube response'); } const author = renderer.title || ''; const authorId = renderer.externalId || channelId; const authorUrl = `/channel/${authorId}`; const rawAvatarUrl = renderer.avatar?.thumbnails?.[0]?.url || ''; const authorThumbnails = rawAvatarUrl ? getAuthorThumbnails(rawAvatarUrl) : []; // Parse subscriber and video counts let subCount = 0; let videoCount = 0; const metadataRows = data.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows; if (Array.isArray(metadataRows)) { for (const row of metadataRows) { if (Array.isArray(row.metadataParts)) { for (const part of row.metadataParts) { const text = part.text?.content || ''; if (text.includes('subscriber')) { const subText = text.split(/\s+/)[0]; subCount = shortTextToNumber(subText); } else if (text.includes('video')) { const videoText = text.split(/\s+/)[0]; videoCount = shortTextToNumber(videoText); } } } } } const description = renderer.description || ''; return { type: 'channel', author, authorId, authorUrl, authorThumbnails, subCount, videoCount, description, descriptionHtml: description, authorVerified: false, autoGenerated: false }; } function getAuthorThumbnails(avatarUrl: string) { const qualities = [32, 48, 76, 100, 176, 512]; return qualities.map(quality => { let url = avatarUrl; if (url.includes('=s')) { url = url.replace(/=s\d+/, `=s${quality}`); } else { url = url + `=s${quality}`; } return { url, width: quality, height: quality }; }); } function shortTextToNumber(shortText: string): number { if (!shortText) return 0; const match = shortText.match(/(\d+(?:\.\d+)?)\s*([mMkKbB]?)/i); if (!match) return 0; let number = parseFloat(match[1]); const suffix = match[2]?.toLowerCase(); switch (suffix) { case 'k': number *= 1000; break; case 'm': number *= 1000000; break; case 'b': number *= 1000000000; break; } return Math.floor(number); }