feat: add debug logging and fix gift membership badge extraction

This commit is contained in:
Yusuf İpek
2025-10-08 22:31:29 +03:00
parent c131913b8a
commit f85b2a942f
3 changed files with 75 additions and 16 deletions
+12
View File
@@ -38,6 +38,12 @@ export async function startBackend() {
ingestion = await bootstrapInnertube(parsedLiveId); ingestion = await bootstrapInnertube(parsedLiveId);
console.log(`[Backend] ✓ YouTube chat connected successfully`); console.log(`[Backend] ✓ YouTube chat connected successfully`);
ingestion.emitter.on('message', (message) => { ingestion.emitter.on('message', (message) => {
// Debug: Log all message attributes
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('[Message Debug] Full message object:');
console.log(JSON.stringify(message, null, 2));
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
store.push(message); store.push(message);
if (store.length > MAX_MESSAGES) { if (store.length > MAX_MESSAGES) {
store.splice(0, store.length - MAX_MESSAGES); store.splice(0, store.length - MAX_MESSAGES);
@@ -105,6 +111,12 @@ export async function startBackend() {
console.log(`[Backend] ✓ YouTube chat connected successfully`); console.log(`[Backend] ✓ YouTube chat connected successfully`);
ingestion.emitter.on('message', (message) => { ingestion.emitter.on('message', (message) => {
// Debug: Log all message attributes
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('[Message Debug] Full message object:');
console.log(JSON.stringify(message, null, 2));
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
store.push(message); store.push(message);
if (store.length > MAX_MESSAGES) { if (store.length > MAX_MESSAGES) {
store.splice(0, store.length - MAX_MESSAGES); store.splice(0, store.length - MAX_MESSAGES);
+63 -15
View File
@@ -156,6 +156,31 @@ function extractBadges(item: any): Badge[] {
return badges; return badges;
} }
function extractBadgesFromHeader(header: any): Badge[] {
const badges: Badge[] = [];
if (!header?.author_badges) return badges;
for (const badge of header.author_badges) {
const label = badge.tooltip ?? '';
const iconType = badge.icon_type ?? '';
if (label.toLowerCase().includes('moderator') || iconType === 'MODERATOR') {
badges.push({ type: 'moderator', label });
} else if (label.toLowerCase().includes('member')) {
badges.push({ type: 'member', label });
} else if (label.toLowerCase().includes('verified') || iconType === 'VERIFIED') {
badges.push({ type: 'verified', label });
} else if (iconType === 'OWNER') {
badges.push({ type: 'custom', label: label || 'Owner' });
} else if (label) {
badges.push({ type: 'custom', label });
}
}
return badges;
}
function extractSuperChatInfo(item: any): SuperChatInfo | undefined { function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
const superTypes = new Set([ const superTypes = new Set([
'LiveChatPaidMessage', 'LiveChatPaidMessage',
@@ -245,6 +270,13 @@ function normalizeAction(action: any): ChatMessage | null {
const item = action.item; const item = action.item;
if (!item) return null; if (!item) return null;
// Debug: Log raw item from YouTube API
console.log('╔════════════════════════════════════════════════════════════════╗');
console.log('║ RAW INNERTUBE API ITEM ║');
console.log('╚════════════════════════════════════════════════════════════════╝');
console.log(JSON.stringify(item, null, 2));
console.log('╚════════════════════════════════════════════════════════════════╝');
// Normalize various live chat events // Normalize various live chat events
const itemType = String(item.type || '').trim(); const itemType = String(item.type || '').trim();
const messageText = resolveMessageText(item).toLowerCase(); const messageText = resolveMessageText(item).toLowerCase();
@@ -279,40 +311,56 @@ function normalizeAction(action: any): ChatMessage | null {
} }
if (isText || isPaid || isMembership || isGiftPurchase) { if (isText || isPaid || isMembership || isGiftPurchase) {
const badges = extractBadges(item); // For gift purchases, author info is in header
const authorSource = isGiftPurchase ? item.header : item;
const badges = isGiftPurchase ? extractBadgesFromHeader(item.header) : extractBadges(item);
const isModerator = badges.some(b => b.type === 'moderator'); const isModerator = badges.some(b => b.type === 'moderator');
const isMember = badges.some(b => b.type === 'member'); const isMember = badges.some(b => b.type === 'member');
const isVerified = badges.some(b => b.type === 'verified'); const isVerified = badges.some(b => b.type === 'verified');
// Extract membership level for new members // Extract membership level for new members and milestones
let membershipLevel: string | undefined; let membershipLevel: string | undefined;
if (isMembership || isGiftPurchase || isPaid) { if (isMembership) {
membershipLevel = item.header_subtext?.toString() || // For membership items, header_primary_text contains milestone info like "Member for 9 months"
membershipLevel = item.header_primary_text?.text ||
item.header_primary_text?.toString() || item.header_primary_text?.toString() ||
(isPaid ? undefined : 'New member'); 'New member';
} else if (isGiftPurchase) {
// For gift purchases, we don't need membership level
membershipLevel = undefined;
} else if (isPaid) {
membershipLevel = undefined;
} }
// Extract gift count for gift purchases // Extract gift count for gift purchases
let giftCount: number | undefined; let giftCount: number | undefined;
if (isGiftPurchase) { if (isGiftPurchase) {
const text = resolveMessageText(item); const primaryText = item.header?.primary_text?.text || '';
const headerText = item.header_primary_text?.toString() || item.header_subtext?.toString() || ''; // Extract number from "Sent 1 Yusuf İpek gift memberships"
const combinedText = text + ' ' + headerText; const countMatch = primaryText.match(/sent\s+(\d+)\s+/i);
// Try to extract number from text like "Gifted 5 memberships", "Sent 5 gift memberships", or "5 memberships"
const countMatch = combinedText.match(/(?:sent|gifted)?\s*(\d+)\s*(?:gift\s*)?(?:membership|memberships|member|members)/i);
if (countMatch) { if (countMatch) {
giftCount = parseInt(countMatch[1], 10); giftCount = parseInt(countMatch[1], 10);
} }
} }
// Extract channel ID from author object (item.author.id contains the YouTube channel ID) // Extract channel ID - for gifts it's in author_external_channel_id
const authorChannelId = item.author?.id; const authorChannelId = isGiftPurchase
? item.author_external_channel_id
: item.author?.id;
// Extract author name and photo
const authorName = isGiftPurchase
? (item.header?.author_name?.text || 'Unknown')
: String(item.author?.name ?? 'Unknown');
const authorPhoto = isGiftPurchase
? item.header?.author_photo?.[0]?.url
: item.author?.thumbnails?.[0]?.url;
return { return {
id: String(item.id ?? item.timestamp_usec ?? Date.now()), id: String(item.id ?? item.timestamp_usec ?? Date.now()),
author: String(item.author?.name ?? 'Unknown'), author: authorName,
authorPhoto: item.author?.thumbnails?.[0]?.url, authorPhoto,
authorChannelId: authorChannelId ? String(authorChannelId) : undefined, authorChannelId: authorChannelId ? String(authorChannelId) : undefined,
text: resolveMessageText(item), text: resolveMessageText(item),
publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec), publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec),
-1
View File
@@ -8,7 +8,6 @@
3. Client dashboard fetches chat data via REST and pushes selection updates via REST. 3. Client dashboard fetches chat data via REST and pushes selection updates via REST.
4. Overlay page consumes a Server-Sent Events stream to stay in sync with the selected message. 4. Overlay page consumes a Server-Sent Events stream to stay in sync with the selected message.
- **Realtime Delivery:** SSE for one-directional updates to OBS browser source; leave room to switch to WebSockets if we need bidirectional control later. - **Realtime Delivery:** SSE for one-directional updates to OBS browser source; leave room to switch to WebSockets if we need bidirectional control later.
- **Configuration:** `.env.local` (or process env) supplies `YOUTUBE_LIVE_ID` and optional Innertube overrides; backend picks mock mode automatically when unset.
## Key Patterns & Practices ## Key Patterns & Practices
- Abstract ingestion behind a module (`backend/src/ingestion/youtubei.ts`) so alternate providers (official API, headless browser) can be swapped in quickly. - Abstract ingestion behind a module (`backend/src/ingestion/youtubei.ts`) so alternate providers (official API, headless browser) can be swapped in quickly.