Commit current project
This commit is contained in:
13
src/pages/api/parent-domain.ts
Normal file
13
src/pages/api/parent-domain.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const hostHeader = request.headers.get("host") || "";
|
||||
const host = hostHeader.split(":")[0];
|
||||
const isIpAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host);
|
||||
const parentDomain = isIpAddress ? "mixchat.local" : host;
|
||||
|
||||
return new Response(JSON.stringify({ parentDomain }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
190
src/pages/api/refresh-token.ts
Normal file
190
src/pages/api/refresh-token.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { refreshTwitchAccessToken } from "../../lib/twitch";
|
||||
import { refreshYoutubeToken } from "../../lib/youtube";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const isDev = import.meta.env.MODE === 'development';
|
||||
const isSecure = !isDev;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { platform } = body as { platform?: string };
|
||||
|
||||
if (!platform) {
|
||||
return new Response(JSON.stringify({ error: "Platform required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken: string | undefined;
|
||||
let refreshTokenOut: string | undefined;
|
||||
let expiresIn: number | undefined;
|
||||
|
||||
if (platform === "twitch") {
|
||||
// Read the refresh token from the secure httpOnly cookie.
|
||||
const refreshToken = cookies.get("twitch_refresh_token")?.value;
|
||||
if (!refreshToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No refresh token available" }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||
const clientSecret = import.meta.env.TWITCH_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error("Missing Twitch credentials in environment");
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Server configuration error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await refreshTwitchAccessToken(
|
||||
refreshToken,
|
||||
clientId,
|
||||
clientSecret,
|
||||
);
|
||||
accessToken = tokenData.accessToken;
|
||||
refreshTokenOut = tokenData.refreshToken;
|
||||
expiresIn = tokenData.expiresIn;
|
||||
|
||||
if (accessToken) {
|
||||
cookies.set("twitch_token", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: expiresIn || 3600,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshTokenOut) {
|
||||
cookies.set("twitch_refresh_token", refreshTokenOut, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
} else if (platform === "youtube") {
|
||||
// Read the refresh token from the secure httpOnly cookie.
|
||||
const refreshToken = cookies.get("youtube_refresh_token")?.value;
|
||||
if (!refreshToken) {
|
||||
// 401 is expected when user hasn't logged in with YouTube yet
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No refresh token available" }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = import.meta.env.PUBLIC_YOUTUBE_CLIENT_ID;
|
||||
const clientSecret = import.meta.env.YOUTUBE_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
console.error("Missing YouTube credentials in environment");
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Server configuration error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await refreshYoutubeToken(
|
||||
refreshToken,
|
||||
clientId,
|
||||
clientSecret,
|
||||
);
|
||||
accessToken = tokenData.access_token;
|
||||
refreshTokenOut = tokenData.refresh_token;
|
||||
expiresIn = tokenData.expires_in;
|
||||
|
||||
if (accessToken) {
|
||||
cookies.set("youtube_token", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: expiresIn || 3600,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshTokenOut) {
|
||||
cookies.set("youtube_refresh_token", refreshTokenOut, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return new Response(JSON.stringify({ error: "Unsupported platform" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Token refreshed successfully", {
|
||||
platform,
|
||||
expiresIn,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken,
|
||||
refreshToken: refreshTokenOut,
|
||||
expiresIn,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
let statusCode = 500;
|
||||
if (
|
||||
errorMessage.includes("401") ||
|
||||
errorMessage.includes("Invalid refresh token")
|
||||
) {
|
||||
statusCode = 401;
|
||||
} else if (errorMessage.includes("400")) {
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
// Log full details server-side; return only a generic message to the client.
|
||||
console.error("Token refresh failed:", {
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: statusCode,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
statusCode === 401
|
||||
? "Session expired. Please log in again."
|
||||
: "Token refresh failed. Please try again.",
|
||||
}),
|
||||
{
|
||||
status: statusCode,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
142
src/pages/api/stream-playlist.ts
Normal file
142
src/pages/api/stream-playlist.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const channel = url.searchParams.get("channel");
|
||||
if (!channel) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Channel parameter required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Grab a playback access token from Twitch on the server side.
|
||||
// The token is tied to the server's IP, so everything that follows
|
||||
// must also go through the server to avoid 403 errors from the CDN.
|
||||
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Client-ID": GQL_CLIENT_ID,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operationName: "PlaybackAccessToken",
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash:
|
||||
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channel,
|
||||
isVod: false,
|
||||
vodID: "",
|
||||
playerType: "embed",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!gqlRes.ok) {
|
||||
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
|
||||
}
|
||||
|
||||
const gqlData = await gqlRes.json();
|
||||
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
|
||||
|
||||
if (!tokenData?.value || !tokenData?.signature) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Stream not available or channel is offline" }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the master playlist from Twitch's CDN using the token we just got.
|
||||
const usherParams = new URLSearchParams({
|
||||
client_id: GQL_CLIENT_ID,
|
||||
token: tokenData.value,
|
||||
sig: tokenData.signature,
|
||||
allow_source: "true",
|
||||
allow_audio_only: "true",
|
||||
allow_spectre: "false",
|
||||
fast_bread: "true",
|
||||
p: String(Math.floor(Math.random() * 999999)),
|
||||
});
|
||||
|
||||
const usherUrl = `https://usher.ttvnw.net/api/channel/hls/${encodeURIComponent(channel)}.m3u8?${usherParams}`;
|
||||
const masterRes = await fetch(usherUrl);
|
||||
|
||||
if (!masterRes.ok) {
|
||||
if (masterRes.status === 404) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Channel is offline or does not exist" }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
throw new Error(`Usher fetch failed: ${masterRes.status}`);
|
||||
}
|
||||
|
||||
const masterText = await masterRes.text();
|
||||
|
||||
// The master playlist lists qualities from best to worst, so the first URL is source quality.
|
||||
const masterLines = masterText.split("\n");
|
||||
let bestVariantUrl = "";
|
||||
for (const line of masterLines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("http")) {
|
||||
bestVariantUrl = trimmed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestVariantUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Could not parse master playlist" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the quality-specific sub-playlist. This one contains the actual .ts segment URLs.
|
||||
const variantRes = await fetch(bestVariantUrl);
|
||||
if (!variantRes.ok) {
|
||||
throw new Error(`Variant playlist fetch failed: ${variantRes.status}`);
|
||||
}
|
||||
|
||||
const variantText = await variantRes.text();
|
||||
|
||||
// Rewrite every segment URL to go through our proxy so the browser
|
||||
// doesn't run into CORS issues when HLS.js fetches the video chunks.
|
||||
const rewrittenVariant = variantText
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("http")) {
|
||||
return `/api/stream-segment?url=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return new Response(rewrittenVariant, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.apple.mpegurl",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
// Tell HLS.js to always re-fetch — this is a live stream
|
||||
"Cache-Control": "no-cache, no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Stream playlist proxy error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to get playlist",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
106
src/pages/api/stream-segment.ts
Normal file
106
src/pages/api/stream-segment.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
/**
|
||||
* Hostnames that this proxy is permitted to fetch on behalf of the client.
|
||||
* Twitch stream segments are served exclusively from these CDN domains.
|
||||
* Any URL pointing elsewhere is rejected to prevent SSRF attacks.
|
||||
*/
|
||||
const ALLOWED_HOSTS = new Set([
|
||||
"video.twitch.tv",
|
||||
"usher.twitchsvc.net",
|
||||
"usher.twitch.tv",
|
||||
"usher.ttvnw.net",
|
||||
"d1m7jfoe9zdc1j.cloudfront.net", // Twitch CloudFront CDN
|
||||
"vod.twitch.tv",
|
||||
// Twitch HLS CDN — segment URLs are served from dynamic subdomains of ttvnw.net
|
||||
// e.g. use14.playlist.ttvnw.net, vod.us-east-2.aws.ttvnw.net
|
||||
]);
|
||||
|
||||
// Allow any *.ttvnw.net subdomain (Twitch's primary HLS CDN)
|
||||
function isAllowedHost(hostname: string): boolean {
|
||||
return ALLOWED_HOSTS.has(hostname) || hostname.endsWith(".ttvnw.net");
|
||||
}
|
||||
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const remote = url.searchParams.get("url");
|
||||
if (!remote) {
|
||||
return new Response(JSON.stringify({ error: "url parameter required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(decodeURIComponent(remote));
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid URL" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce HTTPS and restrict to known Twitch CDN hostnames.
|
||||
if (parsed.protocol !== "https:") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only HTTPS URLs are allowed" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAllowedHost(parsed.hostname)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Disallowed host: ${parsed.hostname}` }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the remote segment/playlist from server-side (avoid browser CORS)
|
||||
const res = await fetch(parsed.href, {
|
||||
headers: {
|
||||
"User-Agent": "Mixchat/1.0",
|
||||
Accept: "*/*",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error(
|
||||
"Failed fetching remote segment",
|
||||
res.status,
|
||||
parsed.href,
|
||||
text.slice(0, 200),
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Remote fetch failed: ${res.status}` }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Clone response body and forward content-type
|
||||
const contentType =
|
||||
res.headers.get("content-type") || "application/octet-stream";
|
||||
const body = await res.arrayBuffer();
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
// Allow range requests if remote supports it
|
||||
"Accept-Ranges": res.headers.get("accept-ranges") || "bytes",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Stream segment proxy error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to proxy segment",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
81
src/pages/api/stream-url.ts
Normal file
81
src/pages/api/stream-url.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
try {
|
||||
const channel = url.searchParams.get("channel");
|
||||
|
||||
if (!channel) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Channel parameter required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch only the playback access token from Twitch GQL (server-side).
|
||||
// We do NOT fetch the CDN playlist URL here — that must be done by the
|
||||
// browser so that the CDN token is bound to the client's IP, not ours.
|
||||
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Client-ID": GQL_CLIENT_ID,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operationName: "PlaybackAccessToken",
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash:
|
||||
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channel,
|
||||
isVod: false,
|
||||
vodID: "",
|
||||
playerType: "embed",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!gqlRes.ok) {
|
||||
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
|
||||
}
|
||||
|
||||
const gqlData = await gqlRes.json();
|
||||
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
|
||||
|
||||
if (!tokenData?.value || !tokenData?.signature) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Stream not available or channel is offline" }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Return the token & sig to the client.
|
||||
// The browser will construct the usher.ttvnw.net URL itself so all CDN
|
||||
// segment requests originate from the correct client IP (fixing 403s).
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
token: tokenData.value,
|
||||
sig: tokenData.signature,
|
||||
clientId: GQL_CLIENT_ID,
|
||||
channel,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Stream URL error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to get stream URL",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
97
src/pages/api/twitch-proxy.ts
Normal file
97
src/pages/api/twitch-proxy.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
/**
|
||||
* Allowlist of Twitch Helix API path prefixes this proxy is permitted to call.
|
||||
* Any path not matching one of these is rejected with 400.
|
||||
* This prevents the endpoint being abused to reach arbitrary external services.
|
||||
*/
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
"/helix/chat/emotes/global",
|
||||
"/helix/chat/emotes/user",
|
||||
"/helix/chat/emotes", // covers /helix/chat/emotes?broadcaster_id=...
|
||||
"/helix/chat/badges/global",
|
||||
"/helix/chat/badges", // covers /helix/chat/badges?broadcaster_id=...
|
||||
] as const;
|
||||
|
||||
const TWITCH_API_BASE = "https://api.twitch.tv";
|
||||
|
||||
/**
|
||||
* Server-side proxy for Twitch Helix API calls.
|
||||
*
|
||||
* Purpose: The client no longer needs to store the Twitch access token in
|
||||
* localStorage to make Twitch API calls (emotes, badges). Instead it calls
|
||||
* this endpoint, which reads the token from the httpOnly "twitch_token" cookie
|
||||
* and forwards the request to Twitch on the server side.
|
||||
*
|
||||
* Usage: GET /api/twitch-proxy?path=/helix/chat/emotes/global
|
||||
* GET /api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=12345
|
||||
* GET /api/twitch-proxy?path=/helix/chat/badges/global
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url, cookies }) => {
|
||||
try {
|
||||
const path = url.searchParams.get("path");
|
||||
if (!path) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "path parameter required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the path against the allowlist
|
||||
const isAllowed = ALLOWED_PATH_PREFIXES.some((prefix) =>
|
||||
path === prefix || path.startsWith(prefix + "?") || path.startsWith(prefix + "/"),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Path not permitted: ${path}` }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Read the access token from the httpOnly cookie — never from client input.
|
||||
const accessToken = cookies.get("twitch_token")?.value;
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Not authenticated" }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Server configuration error" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Build the full Twitch API URL, forwarding any extra query params from the
|
||||
// original request (e.g. broadcaster_id, user_id, after cursor).
|
||||
const forwardParams = new URLSearchParams(url.searchParams);
|
||||
forwardParams.delete("path"); // "path" is our own param, not Twitch's
|
||||
|
||||
const targetUrl = `${TWITCH_API_BASE}${path}${forwardParams.size > 0 ? `?${forwardParams}` : ""
|
||||
}`;
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
"Client-Id": clientId,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Twitch proxy error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Proxy request failed" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
};
|
||||
35
src/pages/api/user-info.ts
Normal file
35
src/pages/api/user-info.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getTwitchUserById } from "../../lib/twitch";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!userId) {
|
||||
return new Response("User ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return new Response("Authorization header is required", { status: 401 });
|
||||
}
|
||||
|
||||
const accessToken = authHeader.replace("Bearer ", "");
|
||||
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
|
||||
|
||||
try {
|
||||
const user = await getTwitchUserById(accessToken, clientId, userId);
|
||||
|
||||
if (!user) {
|
||||
return new Response("User not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(user), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user info:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
154
src/pages/api/youtube-chat.ts
Normal file
154
src/pages/api/youtube-chat.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
/**
|
||||
* 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 }) => {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { videoId, message, accessToken } = body as {
|
||||
videoId?: string;
|
||||
message?: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
|
||||
if (!videoId || !message || !accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "videoId, message, and accessToken are required",
|
||||
}),
|
||||
{ status: 400, headers },
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
const liveChatId = liveChatResult.liveChatId;
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await sendRes.json();
|
||||
return new Response(JSON.stringify({ success: true, id: result.id }), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("youtube-chat API error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : "Internal server error",
|
||||
}),
|
||||
{ status: 500, headers },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: "" };
|
||||
}
|
||||
206
src/pages/api/youtube-emotes.ts
Normal file
206
src/pages/api/youtube-emotes.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
359
src/pages/api/youtube-live.ts
Normal file
359
src/pages/api/youtube-live.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
// Simple in-memory cache and in-flight dedupe to avoid excessive YouTube API calls.
|
||||
|
||||
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<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 = {
|
||||
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, s-maxage=600",
|
||||
};
|
||||
|
||||
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" }),
|
||||
{ status: 400, headers },
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ videoId, channelId }), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("youtube-live API error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : "Internal server error",
|
||||
}),
|
||||
{ status: 500, headers },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// /c/CustomName or /user/Username – try forUsername first, then search
|
||||
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> {
|
||||
// YouTube Data API v3 – channels.list with forHandle
|
||||
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);
|
||||
}
|
||||
}
|
||||
243
src/pages/api/youtube-stream-chat.ts
Normal file
243
src/pages/api/youtube-stream-chat.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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.
|
||||
|
||||
interface MessagePart {
|
||||
type: 'text' | 'emoji';
|
||||
text?: string; // for text parts
|
||||
url?: string; // for emoji/emote parts
|
||||
alt?: string; // for emoji/emote parts
|
||||
emojiText?: string; // shortcode like ":slightly_smiling_face:"
|
||||
isCustomEmoji?: boolean;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
author: string;
|
||||
authorAvatar?: string;
|
||||
badges?: Record<string, string>;
|
||||
parts: MessagePart[];
|
||||
timestamp: Date;
|
||||
superchat?: {
|
||||
amount: string;
|
||||
color: string;
|
||||
sticker?: {
|
||||
url: string;
|
||||
alt: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface YTEmoteEntry {
|
||||
name: string;
|
||||
url: string;
|
||||
isCustomEmoji: boolean;
|
||||
}
|
||||
|
||||
// Global map to store active LiveChat instances and their message caches
|
||||
const activeLiveChats = new Map<
|
||||
string,
|
||||
{
|
||||
chat: LiveChat;
|
||||
messages: ChatMessage[];
|
||||
/** Deduplicated emote registry: name → entry */
|
||||
emoteMap: Map<string, YTEmoteEntry>;
|
||||
lastUpdate: number;
|
||||
}
|
||||
>();
|
||||
|
||||
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 {
|
||||
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 messages: ChatMessage[] = [];
|
||||
const emoteMap = new Map<string, YTEmoteEntry>();
|
||||
|
||||
// Handle incoming messages
|
||||
chat.on('chat', (chatItem: any) => {
|
||||
// Use the library's message ID if available, otherwise generate a stable one
|
||||
// chatItem.id is the actual unique ID from YouTube if the library provides it.
|
||||
const messageId = chatItem.id || `${chatItem.author?.channelId || 'anon'}-${chatItem.timestamp?.getTime() || Date.now()}`;
|
||||
|
||||
// Deduplicate messages by ID to prevent duplicates if the scraper re-emits them
|
||||
if (messages.some(m => m.id === messageId)) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// Handle stream end
|
||||
chat.on('end', () => {
|
||||
console.log('YouTube stream ended for videoId:', videoId);
|
||||
if (activeLiveChats.get(videoId)?.chat === chat) {
|
||||
activeLiveChats.delete(videoId);
|
||||
}
|
||||
});
|
||||
|
||||
// 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' } }
|
||||
);
|
||||
}
|
||||
|
||||
chatSession = {
|
||||
chat,
|
||||
messages,
|
||||
emoteMap,
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
}
|
||||
|
||||
// Update last access time
|
||||
chatSession.lastUpdate = Date.now();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
videoId,
|
||||
messages: chatSession.messages,
|
||||
messageCount: chatSession.messages.length,
|
||||
// All unique emotes/emoji seen so far across all messages
|
||||
emotes: Array.from(chatSession.emoteMap.values()),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('YouTube stream chat API error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user