metadata/utils/youtube-channel.ts

645 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-05-24 16:50:48 +00:00
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);
}