From f9540a89bff1f1e27f86c9c6e81d7a256480b38a Mon Sep 17 00:00:00 2001 From: localhost Date: Sun, 24 May 2026 18:50:48 +0200 Subject: [PATCH] direct youtube channel fetching --- index.ts | 158 +++++----- utils/youtube-channel.ts | 644 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 727 insertions(+), 75 deletions(-) create mode 100644 utils/youtube-channel.ts diff --git a/index.ts b/index.ts index 64116f2..33a4ef0 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,4 @@ import express from 'express'; -import { Innertube } from 'youtubei.js' import * as cheerio from 'cheerio'; import * as fs from 'node:fs' @@ -12,7 +11,7 @@ import { } from './utils/sabr-stream-factory.js'; import type { SabrPlaybackOptions } from 'googlevideo/sabr-stream'; import { getVideoStreams, downloadStream, getInfo } from './utils/companion'; -import { createInnertubePool } from './utils/innertube-pool.js'; +import { fetchChannelVideos, getVideoThumbnails, parseViewCount, relativeTimeToTimestamp, fetchChannelInfo } from './utils/youtube-channel.js'; const ffmpeg = require('fluent-ffmpeg') @@ -20,8 +19,6 @@ const app = express(); require('express-ws')(app) ffmpeg.setFfmpegPath('/usr/local/bin/ffmpeg') -const maxRetries = 5 -const innertubePool = createInnertubePool(parseInt(Bun.env.INNERTUBE_POOL_SIZE || '4')) let consecutiveFailures = 0; @@ -95,55 +92,64 @@ app.get('/video/:id', async (req, res) => { app.get('/channel/:id', async (req, res) => { try { - const info = await innertubePool.use(async (innertube) => await innertube.getChannel(req.params.id), maxRetries) + const info = await fetchChannelInfo(req.params.id); if (!info) { return res.json({ error: 'ErrorCantConnectToServiceAPI' }) } return res.json(info) - } catch { + } catch (error: any) { + console.error("Error in /channel/:id:", error); return res.json({ error: 'ErrorUnknown' }) } }) + app.get('/videos/:id', async (req, res) => { try { - const videos = await innertubePool.use(async (innertube) => { - for (let attempt = 0; attempt < 3; attempt++) { - const videos: any[] = [] - const channel = await innertube.getChannel(req.params.id) + const channelId = req.params.id; + const videos: any[] = []; + let continuation: string | null = null; - let json = await channel.getVideos() - videos.push(...json.videos) + // Fetch first page + let result = await fetchChannelVideos(channelId, 'newest'); + videos.push(...result.videos); + continuation = result.continuation; - while (json.has_continuation && videos.length < 60) { - json = await getNextPage(json) - videos.push(...json.videos) - } - - if (videos.length) { - return videos - } - } - - return [] - }, maxRetries) - - return res.json(videos) - } catch (e: any) { - console.log(e) - - if (e.message.includes('Tab "videos" not found')) { - return res.json([]) + // Fetch subsequent pages if continuation is available and we have fewer than 60 videos + while (continuation && videos.length < 60) { + result = await fetchChannelVideos(channelId, 'newest', continuation); + videos.push(...result.videos); + continuation = result.continuation; } - return res.json(false) - } + // Format each video to have both YouTubeI and Invidious compatible fields + const formattedVideos = videos.map(v => { + const videoId = v.id || ''; + const authorId = v.authorId || channelId; + return { + ...v, + type: "video", + title: v.title || '', + videoId: videoId, + author: v.author || '', + authorId: authorId, + authorUrl: `/channel/${authorId}`, + videoThumbnails: getVideoThumbnails(videoId), + viewCount: parseViewCount(v.viewCountText), + viewCountText: v.viewCountText || '', + published: relativeTimeToTimestamp(v.publishedText), + publishedText: v.publishedText || '', + lengthSeconds: v.lengthSeconds || 0, + liveNow: v.liveNow || false + }; + }); - async function getNextPage(json: any) { - const page = await json.getContinuation(); - return page; + return res.json(formattedVideos); + } catch (e: any) { + console.error("Error fetching channel videos:", e); + return res.json(false); } }) @@ -220,47 +226,49 @@ app.ws('/download/:id', async (ws, req) => { downloadStream(videoStreamUrl, selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video'), downloadStream(audioStreamUrl, selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio') ]); - } else { - const streamOptions: SabrPlaybackOptions = { - videoQuality: quality, - audioQuality: 'AUDIO_QUALITY_LOW', - enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO - }; - const lease = await innertubePool.acquire() - - try { - const { streamResults } = await createSabrStream(req.params.id, streamOptions, lease.innertube) - const { videoStream, audioStream, selectedFormats } = streamResults - - const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0) - + (selectedFormats.videoFormat.contentLength || 0) - - if (videoSizeTotal > (1_048_576 * config.maxVideoSize) && !config.whitelist.includes(req.params.id)) { - ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.'); - ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.'); - return ws.close() - } else if (!selectedFormats.videoFormat.contentLength) { - ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.') - ws.send('Please try again later.') - return ws.close() - } - - ws.send(`VIDEOSIZE-${videoSizeTotal}`) - audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!); - videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!); - - await Promise.all([ - videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')), - audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio')) - ]); - } catch (error) { - await lease.refresh() - throw error - } finally { - lease.release() - } } + // else { + // const streamOptions: SabrPlaybackOptions = { + // videoQuality: quality, + // audioQuality: 'AUDIO_QUALITY_LOW', + // enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO + // }; + // const lease = await innertubePool.acquire() + + // try { + // const { streamResults } = await createSabrStream(req.params.id, streamOptions, lease.innertube) + // const { videoStream, audioStream, selectedFormats } = streamResults + + // const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0) + // + (selectedFormats.videoFormat.contentLength || 0) + + // if (videoSizeTotal > (1_048_576 * config.maxVideoSize) && !config.whitelist.includes(req.params.id)) { + // ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.'); + // ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.'); + // return ws.close() + // } else if (!selectedFormats.videoFormat.contentLength) { + // ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.') + // ws.send('Please try again later.') + // return ws.close() + // } + + // ws.send(`VIDEOSIZE-${videoSizeTotal}`) + // audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!); + // videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!); + + // await Promise.all([ + // videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')), + // audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio')) + // ]); + // } catch (error) { + // await lease.refresh() + // throw error + // } finally { + // lease.release() + // } + // } + if (audioOutputStream == undefined || videoOutputStream == undefined) { ws.send('This should not happen. Please report it via admin@preservetube.com.') return ws.close() diff --git a/utils/youtube-channel.ts b/utils/youtube-channel.ts new file mode 100644 index 0000000..9703bd2 --- /dev/null +++ b/utils/youtube-channel.ts @@ -0,0 +1,644 @@ +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); +} + +