Docs/Skills and code/src/
src/youtube-channel.js
import { Innertube } from "youtubei.js";
import { config } from "../config.js";
import {
bestThumbnailUrl,
countPrecision,
normalizeChannelVideoNode,
parseAbsoluteDate,
parseCount,
textValue
} from "./video-normalization.js";
import { fetchVideoInfo } from "./video-info-fetch.js";
let youtubeClient = null;
async function getYouTube() {
if (youtubeClient) return youtubeClient;
console.log("[youtube] Creating YouTube client...");
youtubeClient = await Innertube.create();
console.log("[youtube] YouTube client created.");
return youtubeClient;
}
async function resolveChannelId(youtube, channelUrl) {
console.log(`[youtube] Resolving channel URL: ${channelUrl}`);
const endpoint = await youtube.resolveURL(channelUrl);
const browseId = endpoint?.payload?.browseId ?? endpoint?.payload?.browse_id ?? null;
if (!browseId) throw new Error(`Could not resolve channel ID from URL: ${channelUrl}`);
console.log("[youtube] Resolved channel endpoint:", { type: endpoint?.type, browseId });
return browseId;
}
function firstText(...values) {
for (const value of values) {
const text = textValue(value);
if (text != null && text.trim()) return text.trim();
}
return null;
}
function metadataTexts(channel) {
const rows = channel?.header?.content?.metadata?.metadata_rows;
if (!Array.isArray(rows)) return [];
return rows.flatMap(row => row?.metadata_parts ?? [])
.map(part => firstText(part?.text, part))
.filter(Boolean);
}
function parseChannelStats(channel, aboutMetadata) {
const texts = metadataTexts(channel);
const subscriberText = firstText(aboutMetadata?.subscriber_count) ??
texts.find(value => /\bsubscribers?\b/i.test(value)) ?? null;
const videoText = firstText(aboutMetadata?.video_count) ??
texts.find(value => /\bvideos?\b/i.test(value)) ?? null;
const totalViewsText = firstText(aboutMetadata?.view_count) ?? null;
return {
subscriber_count_text: subscriberText,
subscriber_count: parseCount(subscriberText),
subscriber_count_precision: countPrecision(subscriberText),
video_count_text: videoText,
video_count: parseCount(videoText),
video_count_precision: countPrecision(videoText),
total_view_count_text: totalViewsText,
total_view_count: parseCount(totalViewsText),
total_view_count_precision: countPrecision(totalViewsText),
metadata_texts: texts
};
}
function extractKeywords(channel) {
const metadata = channel?.metadata;
const values = Array.isArray(metadata?.tags) ? metadata.tags :
Array.isArray(metadata?.keywords) ? metadata.keywords :
typeof metadata?.keywords === "string" ? metadata.keywords.split(/[;,]/) : [];
return [...new Set(values.map(value => String(value).trim()).filter(Boolean))];
}
function thumbnailsFromChannel(channel) {
const headerImage = channel?.header?.content?.image;
const avatar = headerImage?.avatar?.image ?? headerImage?.image ?? headerImage?.sources ?? [];
const fallback = channel?.metadata?.avatar?.image ?? channel?.metadata?.thumbnails ?? channel?.thumbnails ?? [];
return Array.isArray(avatar) && avatar.length ? avatar : fallback;
}
function thumbnailsFromBanner(channel) {
return channel?.header?.content?.banner?.image ?? channel?.header?.content?.banner?.sources ?? [];
}
function extractLinks(aboutMetadata) {
const candidates = aboutMetadata?.custom_links ?? aboutMetadata?.links ?? aboutMetadata?.custom_links_list ?? [];
if (!Array.isArray(candidates)) return [];
return candidates.map(item => {
const rawUrl = item?.link?.endpoint?.metadata?.url ?? item?.link?.endpoint?.payload?.url ??
item?.link?.text ?? item?.endpoint?.url ?? item?.url_endpoint?.url ?? item?.url ?? item?.endpoint?.metadata?.url ?? null;
const url = typeof rawUrl === "string" && rawUrl && !/^https?:\/\//i.test(rawUrl)
? `https://${rawUrl.replace(/^\/+/, "")}` : rawUrl;
return { title: firstText(item?.title, item?.text, item?.link_text, item?.display_name), url };
}).filter(item => item.title || item.url);
}
function extractAboutFields(about) {
const metadata = about?.metadata ?? about?.about?.metadata ?? {};
const joinedText = firstText(metadata?.joined_date, metadata?.joined_date_text);
const joinedMatch = joinedText?.match(/joined\s+(.+)$/i);
const joinedDate = parseAbsoluteDate(joinedMatch?.[1] ?? joinedText);
return {
description: firstText(metadata?.description),
country: firstText(metadata?.country),
canonical_url: firstText(metadata?.canonical_channel_url, metadata?.canonical_url),
joined_date_text: joinedText,
joined_date: joinedDate.value,
joined_date_precision: joinedDate.precision,
custom_links: extractLinks(metadata),
metadata
};
}
function safeJson(value) {
try { return JSON.stringify(value); } catch { return null; }
}
export async function getChannel(channelUrl) {
const youtube = await getYouTube();
console.log(`[youtube] Loading channel: ${channelUrl}`);
const channelId = await resolveChannelId(youtube, channelUrl);
const channel = await youtube.getChannel(channelId);
const name = firstText(channel?.header?.content?.title, channel?.metadata?.title, channel?.author);
let about = null;
let aboutError = null;
if (typeof channel?.getAbout === "function") {
try {
about = await channel.getAbout();
console.log("[youtube] About tab fetched.");
} catch (error) {
aboutError = error instanceof Error ? error.message : String(error);
console.warn("[youtube] About tab fetch failed; using header metadata:", aboutError);
}
} else if (typeof channel?.getTabByName === "function") {
try {
about = await channel.getTabByName("About");
console.log("[youtube] About tab fetched via getTabByName().");
} catch (error) {
aboutError = error instanceof Error ? error.message : String(error);
console.warn("[youtube] About tab unavailable:", aboutError);
}
}
const aboutFields = extractAboutFields(about);
const stats = parseChannelStats(channel, aboutFields.metadata);
const description = aboutFields.description ??
firstText(channel?.header?.content?.description?.description, channel?.header?.content?.description, channel?.metadata?.description);
const thumbnailUrl = bestThumbnailUrl(thumbnailsFromChannel(channel));
const bannerUrl = bestThumbnailUrl(thumbnailsFromBanner(channel));
const canonicalUrl = aboutFields.canonical_url ?? channelUrl;
const handleMatch = String(canonicalUrl ?? channelUrl).match(/youtube\.com\/@([^/?]+)/i);
const handle = handleMatch?.[1] ? `@${handleMatch[1]}` : null;
const keywords = extractKeywords(channel);
const isVerified = typeof channel?.is_verified === "boolean" ? channel.is_verified :
typeof channel?.verified === "boolean" ? channel.verified : null;
const isArtist = typeof channel?.is_artist === "boolean" ? channel.is_artist : null;
const observedAt = new Date().toISOString();
const result = {
channel,
channel_id: channelId,
channel_url: channelUrl,
canonical_url: canonicalUrl,
handle,
name,
description,
subscriber_count: stats.subscriber_count,
subscriber_count_text: stats.subscriber_count_text,
subscriber_count_precision: stats.subscriber_count_precision,
video_count: stats.video_count,
video_count_text: stats.video_count_text,
video_count_precision: stats.video_count_precision,
total_view_count: stats.total_view_count,
total_view_count_text: stats.total_view_count_text,
total_view_count_precision: stats.total_view_count_precision,
joined_date: aboutFields.joined_date,
joined_date_text: aboutFields.joined_date_text,
joined_date_precision: aboutFields.joined_date_precision,
country: aboutFields.country,
keywords,
thumbnail_url: thumbnailUrl,
banner_url: bannerUrl,
custom_links: aboutFields.custom_links,
custom_links_json: JSON.stringify(aboutFields.custom_links),
is_verified: isVerified,
is_artist: isArtist,
metadata_observed_at: observedAt,
metadata_source: "youtubei_channel_header_and_about",
metadata_raw_json: safeJson({
channel_id: channelId, channel_url: channelUrl, canonical_url: canonicalUrl,
name, description, header_metadata_texts: stats.metadata_texts,
subscriber_count_text: stats.subscriber_count_text, subscriber_count_precision: stats.subscriber_count_precision,
video_count_text: stats.video_count_text, video_count_precision: stats.video_count_precision,
total_view_count_text: stats.total_view_count_text, total_view_count_precision: stats.total_view_count_precision, joined_date_text: aboutFields.joined_date_text,
country: aboutFields.country, custom_links: aboutFields.custom_links,
thumbnail_url: thumbnailUrl, banner_url: bannerUrl,
about_metadata_keys: Object.keys(aboutFields.metadata ?? {}), about_error: aboutError
}),
about_error: aboutError
};
console.log("[youtube] Channel extraction summary:", {
channel_id: result.channel_id,
name: result.name,
subscribers: result.subscriber_count_text,
total_views: result.total_view_count_text,
videos: result.video_count_text,
joined: result.joined_date_text,
country: result.country,
has_thumbnail: Boolean(result.thumbnail_url),
has_banner: Boolean(result.banner_url),
links: result.custom_links.length,
keywords: result.keywords.length,
about_error: result.about_error
});
return result;
}
function getChannelObject(channel) {
if (channel?.channel && typeof channel.channel.getVideos === "function") return channel.channel;
if (channel && typeof channel.getVideos === "function") return channel;
throw new Error("Channel object has no usable YouTube channel API object.");
}
export async function getVideoInfo(videoId, options = {}) {
const youtube = await getYouTube();
const fullInfo = options?.fullInfo === true;
const method = fullInfo ? "getInfo" : "getBasicInfo";
console.log(`[youtube-video] Fetching video info via ${method}: ${videoId}`);
const result = await fetchVideoInfo(youtube, videoId, { fullInfo });
console.log("[youtube-video] Extracted:", {
videoId: result.video_id,
source: result.metadata_source,
hasThumbnail: Boolean(result.thumbnail_url),
views: result.view_count,
likes: result.likes_count,
durationSeconds: result.duration_seconds,
publishedText: result.published_at_text,
category: result.category,
hasDescription: Boolean(result.description),
channelId: result.video_channel_id
});
return result;
}
export async function getChannelVideos(channel, mode, limit) {
const rawChannel = getChannelObject(channel);
const requestedMode = String(mode ?? "").trim();
const maxItems = Math.max(0, Number(limit) || 0);
console.log(`[youtube] Loading channel videos: mode=${requestedMode || "unsorted"}, limit=${maxItems}`);
let sortApplied = false;
let sortError = null;
if (requestedMode && typeof rawChannel.applySort === "function") {
try {
const result = await rawChannel.applySort(requestedMode);
sortApplied = true;
console.log("[youtube] applySort succeeded:", {
requestedMode,
returnedType: result?.constructor?.name ?? null,
returnedKeys: result ? Object.keys(result).slice(0, 20) : []
});
} catch (error) {
sortError = error instanceof Error ? error.message : String(error);
console.warn("[youtube] applySort failed; feed will be labelled unsorted:", { requestedMode, error: sortError });
}
} else if (requestedMode) {
sortError = "applySort() is not available on this channel object";
console.warn("[youtube] Cannot apply sort; feed will be labelled unsorted.", { requestedMode });
}
let page = await rawChannel.getVideos();
const results = [];
const seen = new Set();
let pageNumber = 0;
let rejected = 0;
const maxPages = Math.max(1, Number(config.stage1_5.maxChannelPages) || 1);
while (page && results.length < maxItems && pageNumber < maxPages) {
pageNumber++;
const nodes = Array.isArray(page.videos) ? page.videos : [];
console.log(`[youtube] ${requestedMode || "unsorted"} page ${pageNumber}: ${nodes.length} nodes`, {
accepted: results.length, target: maxItems, hasContinuation: Boolean(page.has_continuation)
});
for (const node of nodes) {
if (results.length >= maxItems) break;
const video = normalizeChannelVideoNode(node, channel?.name ?? rawChannel?.metadata?.title ?? null);
if (!video) { rejected++; continue; }
if (seen.has(video.video_id)) continue;
seen.add(video.video_id);
results.push(video);
console.log("[youtube] normalized feed video:", {
id: video.video_id, title: video.title,
thumbnail: Boolean(video.thumbnail_url), viewText: video.view_count_text,
publishedText: video.published_at_text, duration: video.duration
});
}
if (results.length >= maxItems || !page.has_continuation || typeof page.getContinuation !== "function") break;
if (pageNumber >= maxPages) {
console.warn(`[youtube] Reached maxChannelPages=${maxPages}; collection may be incomplete.`);
break;
}
if (config.stage1_5.requestDelayMs > 0) await new Promise(resolve => setTimeout(resolve, config.stage1_5.requestDelayMs));
page = await page.getContinuation();
}
const sourceMode = sortApplied ? requestedMode.toLowerCase() : "unsorted";
Object.defineProperties(results, {
source_mode: { value: sourceMode, enumerable: false },
requested_mode: { value: requestedMode || null, enumerable: false },
sort_applied: { value: sortApplied, enumerable: false },
sort_error: { value: sortError, enumerable: false }
});
console.log("[youtube] Channel feed complete:", {
requestedMode, sourceMode, sortApplied, sortError,
pages: pageNumber, videos: results.length, rejected
});
return results;
}
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.