From 4f215b59dde5463845fe24c7fd69d2e467282514 Mon Sep 17 00:00:00 2001 From: grepfs17 Date: Mon, 30 Mar 2026 03:08:43 +0200 Subject: [PATCH] Changing the way youtube chat is handled, switched library to LuanRT/YouTube.js thanks to Xeeija :3 --- package.json | 2 +- pnpm-lock.yaml | 111 ++------- src/lib/youtube.ts | 128 ++++------ src/pages/api/youtube-chat.ts | 128 ++-------- src/pages/api/youtube-emotes.ts | 206 ---------------- src/pages/api/youtube-live.ts | 342 ++------------------------- src/pages/api/youtube-stream-chat.ts | 252 ++++++++++---------- 7 files changed, 235 insertions(+), 934 deletions(-) delete mode 100644 src/pages/api/youtube-emotes.ts diff --git a/package.json b/package.json index b5dabc2..704edf6 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "react-dom": "18.3.1", "react-icons": "^5.5.0", "twitch-m3u8": "^1.1.5", - "youtube-chat": "^2.2.0" + "youtubei.js": "^17.0.1" }, "devDependencies": { "@astrojs/node": "^10.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79ee63f..9e5f21d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,9 +46,9 @@ importers: twitch-m3u8: specifier: ^1.1.5 version: 1.1.5 - youtube-chat: - specifier: ^2.2.0 - version: 2.2.0 + youtubei.js: + specifier: ^17.0.1 + version: 17.0.1 devDependencies: '@astrojs/node': specifier: ^10.0.3 @@ -230,6 +230,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -1059,16 +1062,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1186,10 +1183,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1328,10 +1321,6 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1438,10 +1427,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -1552,10 +1537,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -1979,6 +1960,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2063,18 +2048,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -2457,9 +2434,6 @@ packages: run-series@1.1.9: resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2734,9 +2708,6 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} - typed-emitter@2.1.0: - resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3074,8 +3045,8 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - youtube-chat@2.2.0: - resolution: {integrity: sha512-wSPA+1DemDmGRUJVBVD5D+17Xg+mkVjZkb6412UMf9osKb4SeKqMaO3d+SJSVo6Xm/Yc019Yemq3tN7c54ZvXQ==} + youtubei.js@17.0.1: + resolution: {integrity: sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg==} zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -3307,6 +3278,8 @@ snapshots: dependencies: css-tree: 3.2.1 + '@bufbuild/protobuf@2.11.0': {} + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -4130,20 +4103,10 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - axios@1.13.6: - dependencies: - follow-redirects: 1.15.11(debug@4.3.7) - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axobject-query@4.1.0: {} bail@2.0.2: {} @@ -4261,10 +4224,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -4406,8 +4365,6 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 - delayed-stream@1.0.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -4501,13 +4458,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -4622,14 +4572,6 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - fresh@2.0.0: {} fsevents@2.3.3: @@ -5196,6 +5138,8 @@ snapshots: merge-stream@2.0.0: {} + meriyah@6.1.4: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -5387,14 +5331,8 @@ snapshots: transitivePeerDependencies: - supports-color - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -5905,11 +5843,6 @@ snapshots: run-series@1.1.9: {} - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - optional: true - safe-buffer@5.2.1: {} safe-regex-test@1.1.0: @@ -6200,10 +6133,6 @@ snapshots: type-detect@4.1.0: {} - typed-emitter@2.1.0: - optionalDependencies: - rxjs: 7.8.2 - typescript@5.9.3: {} ufo@1.6.3: {} @@ -6465,12 +6394,10 @@ snapshots: yocto-queue@1.2.2: {} - youtube-chat@2.2.0: + youtubei.js@17.0.1: dependencies: - axios: 1.13.6 - typed-emitter: 2.1.0 - transitivePeerDependencies: - - debug + '@bufbuild/protobuf': 2.11.0 + meriyah: 6.1.4 zod@4.3.6: {} diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts index 11334c5..92f797f 100644 --- a/src/lib/youtube.ts +++ b/src/lib/youtube.ts @@ -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 { - 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 { - 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 { - 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(); } diff --git a/src/pages/api/youtube-chat.ts b/src/pages/api/youtube-chat.ts index b5ad202..9348fec 100644 --- a/src/pages/api/youtube-chat.ts +++ b/src/pages/api/youtube-chat.ts @@ -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: "" }; -} diff --git a/src/pages/api/youtube-emotes.ts b/src/pages/api/youtube-emotes.ts deleted file mode 100644 index a30fb73..0000000 --- a/src/pages/api/youtube-emotes.ts +++ /dev/null @@ -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 { - // 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 { - 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(); - - 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, - }; -} diff --git a/src/pages/api/youtube-live.ts b/src/pages/api/youtube-live.ts index 0006dc5..9ba093a 100644 --- a/src/pages/api/youtube-live.ts +++ b/src/pages/api/youtube-live.ts @@ -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(); +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>(); -const inFlightLiveFinds = new Map>(); - -// 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - 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); - } -} diff --git a/src/pages/api/youtube-stream-chat.ts b/src/pages/api/youtube-stream-chat.ts index 3c498fd..14f3b10 100644 --- a/src/pages/api/youtube-stream-chat.ts +++ b/src/pages/api/youtube-stream-chat.ts @@ -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; 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(); - // 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): 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 = {}; + 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): 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): 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}`; +}