Changing the way youtube chat is handled, switched library to LuanRT/YouTube.js thanks to Xeeija :3
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"twitch-m3u8": "^1.1.5",
|
"twitch-m3u8": "^1.1.5",
|
||||||
"youtube-chat": "^2.2.0"
|
"youtubei.js": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/node": "^10.0.3",
|
"@astrojs/node": "^10.0.3",
|
||||||
|
|||||||
111
pnpm-lock.yaml
generated
111
pnpm-lock.yaml
generated
@@ -46,9 +46,9 @@ importers:
|
|||||||
twitch-m3u8:
|
twitch-m3u8:
|
||||||
specifier: ^1.1.5
|
specifier: ^1.1.5
|
||||||
version: 1.1.5
|
version: 1.1.5
|
||||||
youtube-chat:
|
youtubei.js:
|
||||||
specifier: ^2.2.0
|
specifier: ^17.0.1
|
||||||
version: 2.2.0
|
version: 17.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@astrojs/node':
|
'@astrojs/node':
|
||||||
specifier: ^10.0.3
|
specifier: ^10.0.3
|
||||||
@@ -230,6 +230,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.11.0':
|
||||||
|
resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==}
|
||||||
|
|
||||||
'@capsizecss/unpack@4.0.0':
|
'@capsizecss/unpack@4.0.0':
|
||||||
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
|
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1059,16 +1062,10 @@ packages:
|
|||||||
async@3.2.6:
|
async@3.2.6:
|
||||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
axios@1.13.6:
|
|
||||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1186,10 +1183,6 @@ packages:
|
|||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
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:
|
comma-separated-tokens@2.0.3:
|
||||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||||
|
|
||||||
@@ -1328,10 +1321,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
|
resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
delayed-stream@1.0.0:
|
|
||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
|
||||||
engines: {node: '>=0.4.0'}
|
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1438,10 +1427,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
esbuild@0.25.0:
|
||||||
resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==}
|
resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1552,10 +1537,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
form-data@4.0.5:
|
|
||||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
|
||||||
engines: {node: '>= 6'}
|
|
||||||
|
|
||||||
fresh@2.0.0:
|
fresh@2.0.0:
|
||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1979,6 +1960,10 @@ packages:
|
|||||||
merge-stream@2.0.0:
|
merge-stream@2.0.0:
|
||||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
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:
|
micromark-core-commonmark@2.0.3:
|
||||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||||
|
|
||||||
@@ -2063,18 +2048,10 @@ packages:
|
|||||||
micromark@4.0.2:
|
micromark@4.0.2:
|
||||||
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
||||||
|
|
||||||
mime-db@1.52.0:
|
|
||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-db@1.54.0:
|
mime-db@1.54.0:
|
||||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
mime-types@3.0.2:
|
||||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2457,9 +2434,6 @@ packages:
|
|||||||
run-series@1.1.9:
|
run-series@1.1.9:
|
||||||
resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==}
|
resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==}
|
||||||
|
|
||||||
rxjs@7.8.2:
|
|
||||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
@@ -2734,9 +2708,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
|
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
typed-emitter@2.1.0:
|
|
||||||
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
|
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -3074,8 +3045,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
|
|
||||||
youtube-chat@2.2.0:
|
youtubei.js@17.0.1:
|
||||||
resolution: {integrity: sha512-wSPA+1DemDmGRUJVBVD5D+17Xg+mkVjZkb6412UMf9osKb4SeKqMaO3d+SJSVo6Xm/Yc019Yemq3tN7c54ZvXQ==}
|
resolution: {integrity: sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg==}
|
||||||
|
|
||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
@@ -3307,6 +3278,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
css-tree: 3.2.1
|
css-tree: 3.2.1
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.11.0': {}
|
||||||
|
|
||||||
'@capsizecss/unpack@4.0.0':
|
'@capsizecss/unpack@4.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fontkitten: 1.0.3
|
fontkitten: 1.0.3
|
||||||
@@ -4130,20 +4103,10 @@ snapshots:
|
|||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
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: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
bail@2.0.2: {}
|
bail@2.0.2: {}
|
||||||
@@ -4261,10 +4224,6 @@ snapshots:
|
|||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
|
||||||
dependencies:
|
|
||||||
delayed-stream: 1.0.0
|
|
||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
@@ -4406,8 +4365,6 @@ snapshots:
|
|||||||
escodegen: 2.1.0
|
escodegen: 2.1.0
|
||||||
esprima: 4.0.1
|
esprima: 4.0.1
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
@@ -4501,13 +4458,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
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:
|
esbuild@0.25.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.0
|
'@esbuild/aix-ppc64': 0.25.0
|
||||||
@@ -4622,14 +4572,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
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: {}
|
fresh@2.0.0: {}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
@@ -5196,6 +5138,8 @@ snapshots:
|
|||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|
||||||
|
meriyah@6.1.4: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
decode-named-character-reference: 1.3.0
|
decode-named-character-reference: 1.3.0
|
||||||
@@ -5387,14 +5331,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
|
||||||
|
|
||||||
mime-db@1.54.0: {}
|
mime-db@1.54.0: {}
|
||||||
|
|
||||||
mime-types@2.1.35:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.52.0
|
|
||||||
|
|
||||||
mime-types@3.0.2:
|
mime-types@3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.54.0
|
mime-db: 1.54.0
|
||||||
@@ -5905,11 +5843,6 @@ snapshots:
|
|||||||
|
|
||||||
run-series@1.1.9: {}
|
run-series@1.1.9: {}
|
||||||
|
|
||||||
rxjs@7.8.2:
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-regex-test@1.1.0:
|
safe-regex-test@1.1.0:
|
||||||
@@ -6200,10 +6133,6 @@ snapshots:
|
|||||||
|
|
||||||
type-detect@4.1.0: {}
|
type-detect@4.1.0: {}
|
||||||
|
|
||||||
typed-emitter@2.1.0:
|
|
||||||
optionalDependencies:
|
|
||||||
rxjs: 7.8.2
|
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
ufo@1.6.3: {}
|
ufo@1.6.3: {}
|
||||||
@@ -6465,12 +6394,10 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@1.2.2: {}
|
yocto-queue@1.2.2: {}
|
||||||
|
|
||||||
youtube-chat@2.2.0:
|
youtubei.js@17.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.13.6
|
'@bufbuild/protobuf': 2.11.0
|
||||||
typed-emitter: 2.1.0
|
meriyah: 6.1.4
|
||||||
transitivePeerDependencies:
|
|
||||||
- debug
|
|
||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,86 @@
|
|||||||
/**
|
import { Innertube } from 'youtubei.js';
|
||||||
* YouTube OAuth and API Client
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface YouTubeTokenResponse {
|
let yt: Innertube | null = null;
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
export async function getInnertube() {
|
||||||
expires_in: number;
|
if (!yt) {
|
||||||
token_type: string;
|
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(
|
export async function getYoutubeAccessToken(
|
||||||
code: string,
|
code: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientSecret: string,
|
clientSecret: string,
|
||||||
redirectUri: string,
|
redirectUri: string
|
||||||
): Promise<YouTubeTokenResponse> {
|
) {
|
||||||
const params = new URLSearchParams({
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
code,
|
method: 'POST',
|
||||||
client_id: clientId,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
client_secret: clientSecret,
|
body: new URLSearchParams({
|
||||||
redirect_uri: redirectUri,
|
code,
|
||||||
grant_type: "authorization_code",
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
if (!res.ok) {
|
||||||
method: "POST",
|
const err = await res.text();
|
||||||
body: params,
|
throw new Error(`Failed to get YouTube access token: ${err}`);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to get YouTube access token: ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getYoutubeUser(accessToken: string) {
|
||||||
* Get YouTube user information
|
const res = await fetch(
|
||||||
*/
|
'https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true',
|
||||||
export async function getYoutubeUser(
|
|
||||||
accessToken: string,
|
|
||||||
): Promise<YouTubeUser> {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
|
|
||||||
{
|
{
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
Authorization: `Bearer ${accessToken}`,
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to get YouTube user: ${response.statusText}`);
|
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) {
|
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];
|
const channel = data.items[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: channel.id,
|
userId: channel.id,
|
||||||
displayName: channel.snippet.title,
|
displayName: channel.snippet.title,
|
||||||
profileImageUrl: channel.snippet.thumbnails?.default?.url || "",
|
profileImageUrl: channel.snippet.thumbnails.default.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh YouTube access token
|
|
||||||
*/
|
|
||||||
export async function refreshYoutubeToken(
|
export async function refreshYoutubeToken(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientSecret: string,
|
clientSecret: string
|
||||||
): Promise<YouTubeTokenResponse> {
|
) {
|
||||||
const params = new URLSearchParams({
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
refresh_token: refreshToken,
|
method: 'POST',
|
||||||
client_id: clientId,
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
client_secret: clientSecret,
|
body: new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
if (!res.ok) {
|
||||||
method: "POST",
|
const err = await res.text();
|
||||||
body: params,
|
throw new Error(`Failed to refresh YouTube token: ${err}`);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to refresh YouTube token: ${response.statusText}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { getInnertube } from "../../lib/youtube";
|
||||||
|
|
||||||
/**
|
//Sends a message to a YouTube live chat using YouTube.js (Innertube)
|
||||||
* 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)
|
|
||||||
*/
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
const headers = { "Content-Type": "application/json" };
|
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
|
// Update session credentials for this request
|
||||||
const liveChatResult = await getLiveChatId(videoId, accessToken, apiKey);
|
// This allows us to use an existing accessToken from the client
|
||||||
if (!liveChatResult.liveChatId) {
|
(yt.session as any).signIn({
|
||||||
console.error("getLiveChatId failed:", liveChatResult.debugInfo);
|
access_token: accessToken,
|
||||||
return new Response(
|
refresh_token: '', // Not needed for a one-off send
|
||||||
JSON.stringify({
|
expiry_date: new Date(Date.now() + 3600 * 1000).toISOString()
|
||||||
error:
|
});
|
||||||
liveChatResult.debugInfo ||
|
|
||||||
"Could not find live chat for this video. The stream may not be live.",
|
|
||||||
}),
|
|
||||||
{ status: 422, headers },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const liveChatId = liveChatResult.liveChatId;
|
const info = await yt.getInfo(videoId);
|
||||||
|
const liveChat = info.getLiveChat();
|
||||||
|
|
||||||
// 2. Send the message
|
const response = await liveChat.sendMessage(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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sendRes.ok) {
|
if (!response) {
|
||||||
const errData = await sendRes.json().catch(() => ({}));
|
return new Response(JSON.stringify({ error: "Failed to send message" }), {
|
||||||
const errMsg =
|
status: 500,
|
||||||
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,
|
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendRes.json();
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
return new Response(JSON.stringify({ success: true, id: result.id }), {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers,
|
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: "" };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,19 @@
|
|||||||
import type { APIRoute } from "astro";
|
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
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
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 }) => {
|
|
||||||
const headers = {
|
const headers = {
|
||||||
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": "public, max-age=60, s-maxage=600",
|
"Cache-Control": "public, max-age=60",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelUrl = url.searchParams.get("channelUrl");
|
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) {
|
if (!channelUrl) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "channelUrl parameter is required" }),
|
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;
|
// Check cache
|
||||||
if (!apiKey) {
|
const now = Date.now();
|
||||||
return new Response(
|
const cached = liveVideoCache.get(channelUrl);
|
||||||
JSON.stringify({
|
if (cached && cached.expires > now) {
|
||||||
error: "YouTube API key not configured on the server",
|
return new Response(JSON.stringify({ videoId: cached.videoId }), { status: 200, headers });
|
||||||
}),
|
|
||||||
{ status: 500, headers },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Resolve the channel URL to a channel ID
|
const yt = await getInnertube();
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If we have an access token, check if it belongs to this channel
|
// Use getChannel which handles handles, URLs, and IDs
|
||||||
// This allows finding unlisted or private streams for the logged-in user.
|
const channel = await yt.getChannel(channelUrl);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Search for an active live stream on that channel (public)
|
// Check if the channel is currently live
|
||||||
const videoId = await findLiveVideoId(channelId, apiKey);
|
const liveStreams = await (channel as any).getLiveStreams();
|
||||||
if (!videoId) {
|
const videoId = liveStreams.videos && liveStreams.videos.length > 0 ? liveStreams.videos[0].id : null;
|
||||||
// Return 200 with null videoId instead of 404 to avoid console errors.
|
|
||||||
// 404 is still technically correct (the live stream resource isn't there),
|
liveVideoCache.set(channelUrl, { videoId, expires: now + LIVE_CACHE_TTL });
|
||||||
// but it causes noise in browser developer tools for periodic polling.
|
|
||||||
return new Response(
|
// Try to get channel ID from metadata or basic_info
|
||||||
JSON.stringify({
|
const channelId = (channel as any).basic_info?.id || (channel as any).header?.author?.id || "";
|
||||||
videoId: null,
|
|
||||||
error: "No active live stream found for this channel",
|
|
||||||
channelId,
|
|
||||||
}),
|
|
||||||
{ status: 200, headers },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ videoId, channelId }), {
|
return new Response(JSON.stringify({ videoId, channelId }), {
|
||||||
status: 200,
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { LiveChat } from 'youtube-chat';
|
import { getInnertube } from '../../lib/youtube';
|
||||||
|
|
||||||
// Handle YouTube chat via the youtube-chat library.
|
|
||||||
// We cache the chat connection in a global to avoid firing it up on every request.
|
|
||||||
|
|
||||||
interface MessagePart {
|
interface MessagePart {
|
||||||
type: 'text' | 'emoji';
|
type: 'text' | 'emoji';
|
||||||
@@ -36,13 +33,12 @@ interface YTEmoteEntry {
|
|||||||
isCustomEmoji: boolean;
|
isCustomEmoji: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global map to store active LiveChat instances and their message caches
|
// Global map to store active LiveChat sessions
|
||||||
const activeLiveChats = new Map<
|
const activeLiveChats = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
chat: LiveChat;
|
chat: any;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
/** Deduplicated emote registry: name → entry */
|
|
||||||
emoteMap: Map<string, YTEmoteEntry>;
|
emoteMap: Map<string, YTEmoteEntry>;
|
||||||
lastUpdate: number;
|
lastUpdate: number;
|
||||||
}
|
}
|
||||||
@@ -61,77 +57,42 @@ export const GET: APIRoute = async ({ url }) => {
|
|||||||
try {
|
try {
|
||||||
let chatSession = activeLiveChats.get(videoId);
|
let chatSession = activeLiveChats.get(videoId);
|
||||||
|
|
||||||
// Create a new LiveChat session if one doesn't exist
|
|
||||||
if (!chatSession) {
|
if (!chatSession) {
|
||||||
// Use a lock-like mechanism to prevent duplicate sessions from concurrent requests
|
const yt = await getInnertube();
|
||||||
const chat = new LiveChat({
|
const info = await yt.getInfo(videoId);
|
||||||
liveId: videoId,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const chat = info.getLiveChat();
|
||||||
const messages: ChatMessage[] = [];
|
const messages: ChatMessage[] = [];
|
||||||
const emoteMap = new Map<string, YTEmoteEntry>();
|
const emoteMap = new Map<string, YTEmoteEntry>();
|
||||||
|
|
||||||
// Handle incoming messages
|
chat.on('chat-update', (action: any) => {
|
||||||
chat.on('chat', (chatItem: any) => {
|
if (action.type === 'AddChatItemAction') {
|
||||||
// Use the library's message ID if available, otherwise generate a stable one
|
const item = action.item;
|
||||||
// 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()}`;
|
|
||||||
|
|
||||||
// Deduplicate messages by ID to prevent duplicates if the scraper re-emits them
|
if (!item) return;
|
||||||
if (messages.some(m => m.id === messageId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert raw message parts into our typed MessagePart array.
|
let chatMsg: ChatMessage | null = null;
|
||||||
const parts: MessagePart[] = (chatItem.message || []).map((msg: any) => {
|
|
||||||
if (msg.url) {
|
if (item.type === 'LiveChatMessage') {
|
||||||
const name = msg.emojiText || msg.alt || msg.url;
|
chatMsg = parseLiveChatMessage(item, emoteMap);
|
||||||
if (!emoteMap.has(name)) {
|
} else if (item.type === 'LiveChatPaidMessageItem') {
|
||||||
emoteMap.set(name, {
|
chatMsg = parsePaidMessage(item, emoteMap);
|
||||||
name,
|
} else if (item.type === 'LiveChatPaidStickerItem') {
|
||||||
url: msg.url,
|
chatMsg = parsePaidSticker(item, emoteMap);
|
||||||
isCustomEmoji: !!msg.isCustomEmoji,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: 'emoji' as const,
|
|
||||||
url: msg.url,
|
|
||||||
alt: msg.alt || msg.emojiText || '',
|
|
||||||
emojiText: msg.emojiText || '',
|
|
||||||
isCustomEmoji: !!msg.isCustomEmoji,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { type: 'text' as const, text: msg.text ?? String(msg) };
|
|
||||||
});
|
|
||||||
|
|
||||||
const message: ChatMessage = {
|
if (chatMsg) {
|
||||||
id: messageId,
|
// Deduplicate
|
||||||
author: chatItem.author.name || 'Anonymous',
|
if (!messages.some(m => m.id === chatMsg!.id)) {
|
||||||
authorAvatar: chatItem.author.thumbnail?.url,
|
messages.push(chatMsg);
|
||||||
badges: chatItem.author.badge ? { [chatItem.author.badge.title]: chatItem.author.badge.thumbnail?.url } : undefined,
|
if (messages.length > 200) messages.shift();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle errors
|
|
||||||
chat.on('error', (err: any) => {
|
chat.on('error', (err: any) => {
|
||||||
console.error('YouTube LiveChat error:', err);
|
console.error('YouTube LiveChat error:', err);
|
||||||
// Clean up failed session after 30 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (activeLiveChats.get(videoId)?.chat === chat) {
|
if (activeLiveChats.get(videoId)?.chat === chat) {
|
||||||
activeLiveChats.delete(videoId);
|
activeLiveChats.delete(videoId);
|
||||||
@@ -139,21 +100,22 @@ export const GET: APIRoute = async ({ url }) => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle stream end
|
await chat.start();
|
||||||
chat.on('end', () => {
|
|
||||||
console.log('YouTube stream ended for videoId:', videoId);
|
|
||||||
if (activeLiveChats.get(videoId)?.chat === chat) {
|
|
||||||
activeLiveChats.delete(videoId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the chat connection
|
// Populate emote map with initial channel emojis
|
||||||
const ok = await chat.start();
|
const initialEmojis = (chat as any).initial_stats?.emojis;
|
||||||
if (!ok) {
|
if (Array.isArray(initialEmojis)) {
|
||||||
return new Response(
|
for (const emoji of initialEmojis) {
|
||||||
JSON.stringify({ error: 'Failed to connect to YouTube chat' }),
|
const name = emoji.shortcuts?.[0] || emoji.emoji_id || 'emoji';
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
const url = emoji.image?.thumbnails?.[0]?.url;
|
||||||
);
|
if (name && url && !emoteMap.has(name)) {
|
||||||
|
emoteMap.set(name, {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
isCustomEmoji: !!emoji.is_custom_emoji
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chatSession = {
|
chatSession = {
|
||||||
@@ -163,55 +125,9 @@ export const GET: APIRoute = async ({ url }) => {
|
|||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if another request beat us to it while we were waiting for chat.start()
|
activeLiveChats.set(videoId, chatSession);
|
||||||
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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last access time
|
|
||||||
chatSession.lastUpdate = Date.now();
|
chatSession.lastUpdate = Date.now();
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -220,7 +136,6 @@ export const GET: APIRoute = async ({ url }) => {
|
|||||||
videoId,
|
videoId,
|
||||||
messages: chatSession.messages,
|
messages: chatSession.messages,
|
||||||
messageCount: chatSession.messages.length,
|
messageCount: chatSession.messages.length,
|
||||||
// All unique emotes/emoji seen so far across all messages
|
|
||||||
emotes: Array.from(chatSession.emoteMap.values()),
|
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