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,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}`;
}