Docs/Skills and code/src/

src/youtube-channel.js

Code327 lines13.6 KBsrc/

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.