Changing the way youtube chat is handled, switched library to LuanRT/YouTube.js thanks to Xeeija :3

This commit is contained in:
2026-03-30 03:08:43 +02:00
parent bb51c30f2f
commit 4f215b59dd
7 changed files with 235 additions and 934 deletions

View File

@@ -1,114 +1,86 @@
/**
* YouTube OAuth and API Client
*/
import { Innertube } from 'youtubei.js';
export interface YouTubeTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
let yt: Innertube | null = null;
export async function getInnertube() {
if (!yt) {
yt = await Innertube.create();
}
return yt;
}
export interface YouTubeUser {
userId: string;
displayName: string;
profileImageUrl: string;
}
/**
* Exchange authorization code for access token
*/
export async function getYoutubeAccessToken(
code: string,
clientId: string,
clientSecret: string,
redirectUri: string,
): Promise<YouTubeTokenResponse> {
const params = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
redirectUri: string
) {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!response.ok) {
throw new Error(
`Failed to get YouTube access token: ${response.statusText}`,
);
if (!res.ok) {
const err = await res.text();
throw new Error(`Failed to get YouTube access token: ${err}`);
}
return response.json();
return res.json();
}
/**
* Get YouTube user information
*/
export async function getYoutubeUser(
accessToken: string,
): Promise<YouTubeUser> {
const response = await fetch(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
export async function getYoutubeUser(accessToken: string) {
const res = await fetch(
'https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!response.ok) {
throw new Error(`Failed to get YouTube user: ${response.statusText}`);
if (!res.ok) {
const err = await res.text();
throw new Error(`Failed to get YouTube user: ${err}`);
}
const data = await response.json();
const data = await res.json();
if (!data.items || data.items.length === 0) {
throw new Error("No YouTube channel found");
throw new Error('No YouTube channel found for this user');
}
const channel = data.items[0];
return {
userId: channel.id,
displayName: channel.snippet.title,
profileImageUrl: channel.snippet.thumbnails?.default?.url || "",
profileImageUrl: channel.snippet.thumbnails.default.url,
};
}
/**
* Refresh YouTube access token
*/
export async function refreshYoutubeToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): Promise<YouTubeTokenResponse> {
const params = new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
clientSecret: string
) {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!response.ok) {
throw new Error(`Failed to refresh YouTube token: ${response.statusText}`);
if (!res.ok) {
const err = await res.text();
throw new Error(`Failed to refresh YouTube token: ${err}`);
}
return response.json();
return res.json();
}

View File

@@ -1,15 +1,7 @@
import type { APIRoute } from "astro";
import { getInnertube } from "../../lib/youtube";
/**
* POST /api/youtube-chat
*
* Sends a message to a YouTube live chat.
*
* Body (JSON):
* videoId - YouTube video ID of the live stream
* message - Text message to send
* accessToken - YouTube OAuth2 access token (user must have granted live-chat scope)
*/
//Sends a message to a YouTube live chat using YouTube.js (Innertube)
export const POST: APIRoute = async ({ request }) => {
const headers = { "Content-Type": "application/json" };
@@ -30,58 +22,29 @@ export const POST: APIRoute = async ({ request }) => {
);
}
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
const yt = await getInnertube();
// 1. Get the liveChatId from the video
const liveChatResult = await getLiveChatId(videoId, accessToken, apiKey);
if (!liveChatResult.liveChatId) {
console.error("getLiveChatId failed:", liveChatResult.debugInfo);
return new Response(
JSON.stringify({
error:
liveChatResult.debugInfo ||
"Could not find live chat for this video. The stream may not be live.",
}),
{ status: 422, headers },
);
}
// Update session credentials for this request
// This allows us to use an existing accessToken from the client
(yt.session as any).signIn({
access_token: accessToken,
refresh_token: '', // Not needed for a one-off send
expiry_date: new Date(Date.now() + 3600 * 1000).toISOString()
});
const liveChatId = liveChatResult.liveChatId;
const info = await yt.getInfo(videoId);
const liveChat = info.getLiveChat();
// 2. Send the message
const sendRes = await fetch(
"https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
snippet: {
liveChatId,
type: "textMessageEvent",
textMessageDetails: {
messageText: message,
},
},
}),
},
);
const response = await liveChat.sendMessage(message);
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({}));
const errMsg =
errData?.error?.message || `YouTube API error: ${sendRes.status}`;
console.error("YouTube send message error:", errData);
return new Response(JSON.stringify({ error: errMsg }), {
status: sendRes.status,
if (!response) {
return new Response(JSON.stringify({ error: "Failed to send message" }), {
status: 500,
headers,
});
}
const result = await sendRes.json();
return new Response(JSON.stringify({ success: true, id: result.id }), {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers,
});
@@ -95,60 +58,3 @@ export const POST: APIRoute = async ({ request }) => {
);
}
};
/**
* Resolve the liveChatId for a given YouTube video.
* Uses the API key for video lookup (public data), falls back to access token.
*/
async function getLiveChatId(
videoId: string,
accessToken: string,
apiKey?: string,
): Promise<{ liveChatId: string | null; debugInfo: string }> {
// Try with API key first (more reliable for public video data), then fall back to OAuth token
const urlBase = `https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=${encodeURIComponent(videoId)}`;
let res: Response;
if (apiKey) {
res = await fetch(`${urlBase}&key=${apiKey}`);
} else {
res = await fetch(urlBase, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return {
liveChatId: null,
debugInfo: `YouTube videos.list returned ${res.status}: ${errBody.slice(0, 200)}`,
};
}
const data = await res.json();
if (!data.items || data.items.length === 0) {
return {
liveChatId: null,
debugInfo: `No video found for ID "${videoId}". The video may not exist or may be private.`,
};
}
const liveDetails = data.items[0]?.liveStreamingDetails;
if (!liveDetails) {
return {
liveChatId: null,
debugInfo: `Video "${videoId}" has no liveStreamingDetails. It may not be a live stream.`,
};
}
const chatId = liveDetails.activeLiveChatId;
if (!chatId) {
return {
liveChatId: null,
debugInfo: `Video "${videoId}" has liveStreamingDetails but no activeLiveChatId. The stream may have ended.`,
};
}
return { liveChatId: chatId, debugInfo: "" };
}

View File

@@ -1,206 +0,0 @@
import type { APIRoute } from 'astro';
/**
* GET /api/youtube-emotes?videoId=xxx
*
* Fetches the channel's custom emojis (membership emotes) by scraping the
* same YouTube page that the youtube-chat library uses internally.
*
* YouTube's official Data API v3 does NOT expose channel custom emojis, so
* we extract them from ytInitialData embedded in the live-chat frame HTML,
* which is the same source the Innertube API returns to the library.
*/
interface YTEmote {
name: string; // shortcut / emojiText, e.g. ":channelName_emote1:"
url: string; // CDN image URL
isCustomEmoji: boolean;
}
export const GET: APIRoute = async ({ url }) => {
const videoId = url.searchParams.get('videoId');
if (!videoId) {
return new Response(
JSON.stringify({ error: 'videoId query parameter is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const emotes = await fetchCustomEmojis(videoId);
return new Response(
JSON.stringify({ success: true, videoId, emotes }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes — emotes don't change mid-stream
'Cache-Control': 'public, max-age=300',
},
}
);
} catch (error) {
console.error('YouTube emotes fetch error:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to fetch emotes',
emotes: [],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
// Return 200 with empty list instead of 500 — missing emotes is non-fatal
);
}
};
async function fetchCustomEmojis(videoId: string): Promise<YTEmote[]> {
// Step 1: Fetch the watch page — contains ytInitialData with customEmojis
const watchPageUrl = `https://www.youtube.com/watch?v=${videoId}`;
const pageRes = await fetch(watchPageUrl, {
headers: {
// Mimic a browser so YouTube returns the full JS-embedded data
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!pageRes.ok) {
throw new Error(`Failed to fetch YouTube watch page: ${pageRes.status}`);
}
const html = await pageRes.text();
// Step 2: Extract ytInitialData JSON from the page
// YouTube embeds it as: var ytInitialData = {...};
const initDataMatch = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
if (!initDataMatch) {
// Try the live-chat iframe URL instead, which also has customEmojis
return fetchCustomEmojisFromChatFrame(videoId, html);
}
let ytInitialData: any;
try {
ytInitialData = JSON.parse(initDataMatch[1]);
} catch {
return fetchCustomEmojisFromChatFrame(videoId, html);
}
// Step 3: Walk ytInitialData to find customEmojis.
// They live at: contents.liveChatRenderer.customEmojis (in chat embed data)
// or inside engagementPanels → liveChatRenderer
const emotes = extractCustomEmojisFromInitData(ytInitialData);
if (emotes.length > 0) return emotes;
// Fallback: try the dedicated live-chat frame
return fetchCustomEmojisFromChatFrame(videoId, html);
}
/**
* Fallback: fetch https://www.youtube.com/live_chat?v=xxx which is the
* iframe YouTube embeds for chat. Its ytInitialData has a liveChatRenderer
* with customEmojis at the top level.
*/
async function fetchCustomEmojisFromChatFrame(
videoId: string,
_watchHtml?: string
): Promise<YTEmote[]> {
const chatFrameUrl = `https://www.youtube.com/live_chat?v=${videoId}&embed_domain=www.youtube.com`;
const res = await fetch(chatFrameUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!res.ok) return [];
const html = await res.text();
// ytInitialData is embedded the same way in the chat frame
const match = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
if (!match) return [];
let data: any;
try {
data = JSON.parse(match[1]);
} catch {
return [];
}
return extractCustomEmojisFromInitData(data);
}
/**
* Recursively finds any `customEmojis` array in ytInitialData and converts
* each entry to our YTEmote shape.
*
* YouTube's customEmoji entries look like:
* {
* emojiId: "UgkX...",
* shortcuts: [":channelName_emote1:"],
* searchTerms: [...],
* image: { thumbnails: [{ url, width, height }, ...], accessibility: ... },
* isCustomEmoji: true
* }
*/
function extractCustomEmojisFromInitData(data: any): YTEmote[] {
// BFS search for a `customEmojis` key anywhere in the tree
const queue: any[] = [data];
const results: YTEmote[] = [];
const seen = new Set<string>();
while (queue.length > 0) {
const node = queue.shift();
if (!node || typeof node !== 'object') continue;
if (Array.isArray(node.customEmojis)) {
for (const emoji of node.customEmojis) {
const emote = parseCustomEmoji(emoji);
if (emote && !seen.has(emote.name)) {
seen.add(emote.name);
results.push(emote);
}
}
// Don't stop — there might be more (e.g. multiple renderers)
}
// Enqueue child nodes (limit depth to avoid huge traversals)
for (const key of Object.keys(node)) {
const child = node[key];
if (child && typeof child === 'object') {
queue.push(child);
}
}
}
return results;
}
function parseCustomEmoji(emoji: any): YTEmote | null {
if (!emoji || typeof emoji !== 'object') return null;
// Pick the best image URL: prefer a mid-size thumbnail
const thumbnails: any[] = emoji.image?.thumbnails ?? [];
if (thumbnails.length === 0) return null;
// Sort ascending by width and pick the smallest that is >= 32px, else the largest
const sorted = [...thumbnails].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
const preferred = sorted.find((t) => (t.width ?? 0) >= 32) ?? sorted[sorted.length - 1];
const url: string = preferred?.url ?? '';
if (!url) return null;
// Name: prefer the shortcut code (e.g. ":channelName_hi:"), fall back to emojiId
const shortcuts: string[] = emoji.shortcuts ?? [];
const name = shortcuts[0] || emoji.emojiId || '';
if (!name) return null;
return {
name,
url,
isCustomEmoji: true,
};
}

View File

@@ -1,38 +1,19 @@
import type { APIRoute } from "astro";
import { getInnertube } from "../../lib/youtube";
// Simple in-memory cache and in-flight dedupe to avoid excessive YouTube API calls.
// Simple in-memory cache to save on InnerTube calls
const liveVideoCache = new Map<string, { videoId: string | null; expires: number }>();
const LIVE_CACHE_TTL = 1000 * 60 * 5; // 5 minutes
const CHANNEL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
const LIVE_CACHE_TTL = 1000 * 60 * 10; // 10 minutes
const channelResolveCache = new Map<
string,
{ channelId: string; expires: number }
>();
const liveVideoCache = new Map<
string,
{ videoId: string | null; expires: number }
>();
const inFlightChannelResolves = new Map<string, Promise<string | null>>();
const inFlightLiveFinds = new Map<string, Promise<string | null>>();
// Resolve a YouTube channel URL to its active live stream video ID.
// We use the Data API v3 and some local caching to save on quota.
export const GET: APIRoute = async ({ url, request }) => {
export const GET: APIRoute = async ({ url }) => {
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, s-maxage=600",
"Cache-Control": "public, max-age=60",
};
try {
const channelUrl = url.searchParams.get("channelUrl");
// Check Authorization header for accessToken
const authHeader = url.searchParams.get("accessToken") || (typeof request !== 'undefined' ? (request as any).headers.get("Authorization") : null);
const accessToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : authHeader;
if (!channelUrl) {
return new Response(
JSON.stringify({ error: "channelUrl parameter is required" }),
@@ -40,61 +21,26 @@ export const GET: APIRoute = async ({ url, request }) => {
);
}
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({
error: "YouTube API key not configured on the server",
}),
{ status: 500, headers },
);
// Check cache
const now = Date.now();
const cached = liveVideoCache.get(channelUrl);
if (cached && cached.expires > now) {
return new Response(JSON.stringify({ videoId: cached.videoId }), { status: 200, headers });
}
// 1. Resolve the channel URL to a channel ID
const channelId = await resolveChannelId(channelUrl, apiKey);
if (!channelId) {
return new Response(
JSON.stringify({
error: "Could not resolve YouTube channel from the given URL",
}),
{ status: 404, headers },
);
}
const yt = await getInnertube();
// 2. If we have an access token, check if it belongs to this channel
// This allows finding unlisted or private streams for the logged-in user.
if (accessToken) {
try {
const myChannelId = await getMyChannelId(accessToken);
if (myChannelId === channelId) {
const liveId = await findMyLiveVideoId(accessToken);
if (liveId) {
return new Response(JSON.stringify({ videoId: liveId, channelId }), {
status: 200,
headers,
});
}
}
} catch (e) {
console.warn("Auth-based live check failed:", e);
}
}
// Use getChannel which handles handles, URLs, and IDs
const channel = await yt.getChannel(channelUrl);
// 3. Search for an active live stream on that channel (public)
const videoId = await findLiveVideoId(channelId, apiKey);
if (!videoId) {
// Return 200 with null videoId instead of 404 to avoid console errors.
// 404 is still technically correct (the live stream resource isn't there),
// but it causes noise in browser developer tools for periodic polling.
return new Response(
JSON.stringify({
videoId: null,
error: "No active live stream found for this channel",
channelId,
}),
{ status: 200, headers },
);
}
// Check if the channel is currently live
const liveStreams = await (channel as any).getLiveStreams();
const videoId = liveStreams.videos && liveStreams.videos.length > 0 ? liveStreams.videos[0].id : null;
liveVideoCache.set(channelUrl, { videoId, expires: now + LIVE_CACHE_TTL });
// Try to get channel ID from metadata or basic_info
const channelId = (channel as any).basic_info?.id || (channel as any).header?.author?.id || "";
return new Response(JSON.stringify({ videoId, channelId }), {
status: 200,
@@ -111,247 +57,3 @@ export const GET: APIRoute = async ({ url, request }) => {
);
}
};
/**
* Uses a YouTube access token to find the authorized user's channel ID.
*/
async function getMyChannelId(accessToken: string): Promise<string | null> {
const res = await fetch(
"https://www.googleapis.com/youtube/v3/channels?part=id&mine=true",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) return null;
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
return null;
}
/**
* Uses a YouTube access token to find the currently active live broadcast for the authorized user.
*/
async function findMyLiveVideoId(accessToken: string): Promise<string | null> {
const res = await fetch(
"https://www.googleapis.com/youtube/v3/liveBroadcasts?part=id&broadcastStatus=active&mine=true",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
if (res.status === 401) throw new Error("Unauthorized");
return null;
}
const data = await res.json();
if (data.items && data.items.length > 0) {
// Return the video ID (the broadcast ID is identical to the video ID)
return data.items[0].id;
}
return null;
}
// Helper to resolve different styles of YouTube URLs (handles, channel IDs, etc)
// and return the core channel ID.
async function resolveChannelId(
channelUrl: string,
apiKey: string,
): Promise<string | null> {
const now = Date.now();
// Return cached mapping if available
const cached = channelResolveCache.get(channelUrl);
if (cached && cached.expires > now) {
return cached.channelId;
}
// Deduplicate concurrent resolves for the same URL
if (inFlightChannelResolves.has(channelUrl)) {
return await inFlightChannelResolves.get(channelUrl)!;
}
const promise = (async () => {
let parsed: URL;
try {
parsed = new URL(channelUrl);
} catch {
return null;
}
const pathname = parsed.pathname; // e.g. /@lunateac or /channel/UCxyz
// Direct channel ID
if (pathname.startsWith("/channel/")) {
const id = pathname.replace("/channel/", "").split("/")[0];
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
// Handle-based URL (@handle)
if (pathname.startsWith("/@")) {
const handle = pathname.replace("/@", "").split("/")[0];
const id = await resolveHandleViaApi(handle, apiKey);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
if (pathname.startsWith("/c/") || pathname.startsWith("/user/")) {
const name = pathname.split("/")[2];
const id = await resolveByUsernameOrSearch(name, apiKey);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
// Fallback: treat last segment as possible handle
const segments = pathname.split("/").filter(Boolean);
if (segments.length > 0) {
const id = await resolveHandleViaApi(
segments[segments.length - 1],
apiKey,
);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
return null;
})();
inFlightChannelResolves.set(channelUrl, promise);
try {
return await promise;
} finally {
inFlightChannelResolves.delete(channelUrl);
}
}
async function resolveHandleViaApi(
handle: string,
apiKey: string,
): Promise<string | null> {
const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${encodeURIComponent(handle)}&key=${apiKey}`,
);
if (!res.ok) {
console.warn(`YouTube API channels.list forHandle failed: ${res.status}`);
// Fallback to search
return resolveByUsernameOrSearch(handle, apiKey);
}
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
// Fallback to search
return resolveByUsernameOrSearch(handle, apiKey);
}
async function resolveByUsernameOrSearch(
name: string,
apiKey: string,
): Promise<string | null> {
// Try forUsername
const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=id&forUsername=${encodeURIComponent(name)}&key=${apiKey}`,
);
if (res.ok) {
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
}
// Last resort: search for the channel name
const searchRes = await fetch(
`https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(name)}&type=channel&maxResults=1&key=${apiKey}`,
);
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.items && searchData.items.length > 0) {
return searchData.items[0].snippet.channelId;
}
}
return null;
}
// Check if a channel actually has a live stream running right now
async function findLiveVideoId(
channelId: string,
apiKey: string,
): Promise<string | null> {
const now = Date.now();
// Check cache first
const cached = liveVideoCache.get(channelId);
if (cached && cached.expires > now) {
return cached.videoId;
}
// Dedupe concurrent lookups
if (inFlightLiveFinds.has(channelId)) {
return await inFlightLiveFinds.get(channelId)!;
}
const promise = (async () => {
const res = await fetch(
`https://www.googleapis.com/youtube/v3/search?part=id&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
);
if (!res.ok) {
console.warn(`YouTube search for live video failed: ${res.status}`);
// Cache negative result for a short period to avoid thundering retry
liveVideoCache.set(channelId, {
videoId: null,
expires: now + LIVE_CACHE_TTL,
});
return null;
}
const data = await res.json();
if (data.items && data.items.length > 0) {
const vid = data.items[0].id.videoId;
liveVideoCache.set(channelId, {
videoId: vid,
expires: now + LIVE_CACHE_TTL,
});
return vid;
}
// No live video found — cache null for a short period
liveVideoCache.set(channelId, {
videoId: null,
expires: now + LIVE_CACHE_TTL,
});
return null;
})();
inFlightLiveFinds.set(channelId, promise);
try {
return await promise;
} finally {
inFlightLiveFinds.delete(channelId);
}
}

View File

@@ -1,8 +1,5 @@
import type { APIRoute } from 'astro';
import { LiveChat } from 'youtube-chat';
// Handle YouTube chat via the youtube-chat library.
// We cache the chat connection in a global to avoid firing it up on every request.
import { getInnertube } from '../../lib/youtube';
interface MessagePart {
type: 'text' | 'emoji';
@@ -36,13 +33,12 @@ interface YTEmoteEntry {
isCustomEmoji: boolean;
}
// Global map to store active LiveChat instances and their message caches
// Global map to store active LiveChat sessions
const activeLiveChats = new Map<
string,
{
chat: LiveChat;
chat: any;
messages: ChatMessage[];
/** Deduplicated emote registry: name → entry */
emoteMap: Map<string, YTEmoteEntry>;
lastUpdate: number;
}
@@ -61,77 +57,42 @@ export const GET: APIRoute = async ({ url }) => {
try {
let chatSession = activeLiveChats.get(videoId);
// Create a new LiveChat session if one doesn't exist
if (!chatSession) {
// Use a lock-like mechanism to prevent duplicate sessions from concurrent requests
const chat = new LiveChat({
liveId: videoId,
});
const yt = await getInnertube();
const info = await yt.getInfo(videoId);
const chat = info.getLiveChat();
const messages: ChatMessage[] = [];
const emoteMap = new Map<string, YTEmoteEntry>();
// Handle incoming messages
chat.on('chat', (chatItem: any) => {
// Use the library's message ID if available, otherwise generate a stable one
// chatItem.id is the actual unique ID from YouTube if the library provides it.
const messageId = chatItem.id || `${chatItem.author?.channelId || 'anon'}-${chatItem.timestamp?.getTime() || Date.now()}`;
chat.on('chat-update', (action: any) => {
if (action.type === 'AddChatItemAction') {
const item = action.item;
// Deduplicate messages by ID to prevent duplicates if the scraper re-emits them
if (messages.some(m => m.id === messageId)) {
return;
}
if (!item) return;
// Convert raw message parts into our typed MessagePart array.
const parts: MessagePart[] = (chatItem.message || []).map((msg: any) => {
if (msg.url) {
const name = msg.emojiText || msg.alt || msg.url;
if (!emoteMap.has(name)) {
emoteMap.set(name, {
name,
url: msg.url,
isCustomEmoji: !!msg.isCustomEmoji,
});
}
return {
type: 'emoji' as const,
url: msg.url,
alt: msg.alt || msg.emojiText || '',
emojiText: msg.emojiText || '',
isCustomEmoji: !!msg.isCustomEmoji,
};
let chatMsg: ChatMessage | null = null;
if (item.type === 'LiveChatMessage') {
chatMsg = parseLiveChatMessage(item, emoteMap);
} else if (item.type === 'LiveChatPaidMessageItem') {
chatMsg = parsePaidMessage(item, emoteMap);
} else if (item.type === 'LiveChatPaidStickerItem') {
chatMsg = parsePaidSticker(item, emoteMap);
}
return { type: 'text' as const, text: msg.text ?? String(msg) };
});
const message: ChatMessage = {
id: messageId,
author: chatItem.author.name || 'Anonymous',
authorAvatar: chatItem.author.thumbnail?.url,
badges: chatItem.author.badge ? { [chatItem.author.badge.title]: chatItem.author.badge.thumbnail?.url } : undefined,
parts,
timestamp: chatItem.timestamp || new Date(),
superchat: chatItem.superchat ? {
amount: chatItem.superchat.amount,
color: chatItem.superchat.color,
sticker: chatItem.superchat.sticker ? {
url: chatItem.superchat.sticker.url,
alt: chatItem.superchat.sticker.alt,
} : undefined,
} : undefined,
};
messages.push(message);
// Keep only last 200 messages
if (messages.length > 200) {
messages.shift();
if (chatMsg) {
// Deduplicate
if (!messages.some(m => m.id === chatMsg!.id)) {
messages.push(chatMsg);
if (messages.length > 200) messages.shift();
}
}
}
});
// Handle errors
chat.on('error', (err: any) => {
console.error('YouTube LiveChat error:', err);
// Clean up failed session after 30 seconds
setTimeout(() => {
if (activeLiveChats.get(videoId)?.chat === chat) {
activeLiveChats.delete(videoId);
@@ -139,21 +100,22 @@ export const GET: APIRoute = async ({ url }) => {
}, 30000);
});
// Handle stream end
chat.on('end', () => {
console.log('YouTube stream ended for videoId:', videoId);
if (activeLiveChats.get(videoId)?.chat === chat) {
activeLiveChats.delete(videoId);
}
});
await chat.start();
// Start the chat connection
const ok = await chat.start();
if (!ok) {
return new Response(
JSON.stringify({ error: 'Failed to connect to YouTube chat' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
// Populate emote map with initial channel emojis
const initialEmojis = (chat as any).initial_stats?.emojis;
if (Array.isArray(initialEmojis)) {
for (const emoji of initialEmojis) {
const name = emoji.shortcuts?.[0] || emoji.emoji_id || 'emoji';
const url = emoji.image?.thumbnails?.[0]?.url;
if (name && url && !emoteMap.has(name)) {
emoteMap.set(name, {
name,
url,
isCustomEmoji: !!emoji.is_custom_emoji
});
}
}
}
chatSession = {
@@ -163,55 +125,9 @@ export const GET: APIRoute = async ({ url }) => {
lastUpdate: Date.now(),
};
// Check if another request beat us to it while we were waiting for chat.start()
const existing = activeLiveChats.get(videoId);
if (existing) {
// If an existing session exists, stop this one to avoid double scraping
chat.stop();
chatSession = existing;
} else {
activeLiveChats.set(videoId, chatSession);
console.log(`Created LiveChat session for videoId: ${videoId}`);
}
// Try to get channel emotes in the background so autocomplete is snappy.
// This pulls from the initial data scrape so users don't have to wait for them to appear in chat.
(async () => {
try {
// Build an absolute URL from the current request's origin
const origin = url.origin;
const emoteRes = await fetch(
`${origin}/api/youtube-emotes?videoId=${encodeURIComponent(videoId)}`
);
if (emoteRes.ok) {
const emoteData = await emoteRes.json();
if (Array.isArray(emoteData.emotes)) {
const session = activeLiveChats.get(videoId);
if (session) {
for (const e of emoteData.emotes) {
if (e.name && e.url && !session.emoteMap.has(e.name)) {
session.emoteMap.set(e.name, {
name: e.name,
url: e.url,
isCustomEmoji: true,
});
}
}
console.log(
`Pre-loaded ${emoteData.emotes.length} channel emotes for videoId: ${videoId}`
);
}
}
}
} catch (err) {
// Non-fatal — emotes will still accumulate from chat messages
console.warn('Failed to pre-load YouTube emotes:', err);
}
})();
activeLiveChats.set(videoId, chatSession);
}
// Update last access time
chatSession.lastUpdate = Date.now();
return new Response(
@@ -220,7 +136,6 @@ export const GET: APIRoute = async ({ url }) => {
videoId,
messages: chatSession.messages,
messageCount: chatSession.messages.length,
// All unique emotes/emoji seen so far across all messages
emotes: Array.from(chatSession.emoteMap.values()),
}),
{
@@ -241,3 +156,88 @@ export const GET: APIRoute = async ({ url }) => {
);
}
};
function parseLiveChatMessage(msg: any, emoteMap: Map<string, YTEmoteEntry>): ChatMessage {
const parts: MessagePart[] = [];
if (msg.message && msg.message.runs) {
for (const run of msg.message.runs) {
if (run.emoji) {
const emoji = run.emoji;
const name = emoji.shortcuts?.[0] || emoji.emoji_id || 'emoji';
const url = emoji.image.thumbnails[0].url;
const isCustom = !!emoji.is_custom_emoji;
if (!emoteMap.has(name)) {
emoteMap.set(name, { name, url, isCustomEmoji: isCustom });
}
parts.push({
type: 'emoji',
url,
alt: name,
emojiText: name,
isCustomEmoji: isCustom
});
} else {
parts.push({
type: 'text',
text: run.text || ''
});
}
}
}
const badges: Record<string, string> = {};
if (msg.author.badges) {
for (const badge of msg.author.badges) {
if (badge.thumbnails && badge.thumbnails[0]) {
badges[badge.label || 'badge'] = badge.thumbnails[0].url;
}
}
}
return {
id: msg.id,
author: msg.author.name.toString(),
authorAvatar: msg.author.thumbnails?.[0]?.url,
badges,
parts,
timestamp: new Date(parseInt(msg.timestamp) / 1000)
};
}
function parsePaidMessage(msg: any, emoteMap: Map<string, YTEmoteEntry>): ChatMessage {
const base = parseLiveChatMessage(msg, emoteMap);
return {
...base,
superchat: {
amount: msg.purchase_amount,
color: ARGBtoHex(msg.body_background_color),
}
};
}
function parsePaidSticker(msg: any, _emoteMap: Map<string, YTEmoteEntry>): ChatMessage {
return {
id: msg.id,
author: msg.author.name.toString(),
authorAvatar: msg.author.thumbnails?.[0]?.url,
parts: [],
timestamp: new Date(parseInt(msg.timestamp) / 1000),
superchat: {
amount: msg.purchase_amount,
color: ARGBtoHex(msg.background_color),
sticker: {
url: msg.sticker.thumbnails[0].url,
alt: msg.author.name.toString() + ' sticker'
}
}
};
}
//Converts YouTube's ARGB decimal color to Hex
function ARGBtoHex(argb: number): string {
const hex = (argb & 0xFFFFFF).toString(16).padStart(6, '0');
return `#${hex}`;
}