Changing the way youtube chat is handled, switched library to LuanRT/YouTube.js thanks to Xeeija :3
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user