Refactor YouTube API calls to use URLSearchParams for query parameters and improve error handling

This commit is contained in:
2026-03-30 04:28:11 +02:00
parent 4f215b59dd
commit aa24f2f10e
6 changed files with 146 additions and 26 deletions

View File

@@ -380,9 +380,13 @@ export default function AppContainer() {
let cancelled = false; let cancelled = false;
const fetchId = async () => { const fetchId = async () => {
try { try {
const res = await fetch(`/api/youtube-live?channelUrl=${encodeURIComponent(linkedYoutubeUrl)}`, { const params = new URLSearchParams();
headers: sessions.youtube?.accessToken ? { 'Authorization': `Bearer ${sessions.youtube.accessToken}` } : undefined params.append('channelUrl', linkedYoutubeUrl);
}); if (sessions.youtube?.accessToken) {
params.append('accessToken', sessions.youtube.accessToken);
}
const res = await fetch(`/api/youtube-live?${params.toString()}`);
const data = await res.json(); const data = await res.json();
if (!cancelled && res.ok && data.videoId) { if (!cancelled && res.ok && data.videoId) {
setYoutubeStreamVideoId(data.videoId); setYoutubeStreamVideoId(data.videoId);

View File

@@ -130,9 +130,13 @@ export default function ChatApp({
// the YouTube iframe to unmount and remount every poll. Only update // the YouTube iframe to unmount and remount every poll. Only update
// `autoVideoId` after we receive a result, and only if it changed. // `autoVideoId` after we receive a result, and only if it changed.
try { try {
const res = await fetch(`/api/youtube-live?channelUrl=${encodeURIComponent(linkedYoutubeUrl)}`, { const params = new URLSearchParams();
headers: youtubeAccessToken ? { 'Authorization': `Bearer ${youtubeAccessToken}` } : undefined params.append('channelUrl', linkedYoutubeUrl);
}); if (youtubeAccessToken) {
params.append('accessToken', youtubeAccessToken);
}
const res = await fetch(`/api/youtube-live?${params.toString()}`);
const data = await res.json(); const data = await res.json();
if (cancelled) return; if (cancelled) return;

View File

@@ -82,7 +82,13 @@ export default function YouTubeChat({ videoId, accessToken, session, onEmotesDis
try { try {
setError(null); setError(null);
const res = await fetch(`/api/youtube-stream-chat?videoId=${encodeURIComponent(videoId)}`); const params = new URLSearchParams();
params.append('videoId', videoId);
if (accessToken) {
params.append('accessToken', accessToken);
}
const res = await fetch(`/api/youtube-stream-chat?${params.toString()}`);
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({})); const errorData = await res.json().catch(() => ({}));
@@ -94,6 +100,8 @@ export default function YouTubeChat({ videoId, accessToken, session, onEmotesDis
if (!isMounted) return; if (!isMounted) return;
console.log(`[YouTubeChat] Received response: ${data.messageCount} messages, ${(data.emotes || []).length} emotes`);
if (data.success && data.messages) { if (data.success && data.messages) {
const fetchedMessages = (data.messages as any[]).map((msg: any) => ({ const fetchedMessages = (data.messages as any[]).map((msg: any) => ({
id: msg.id, id: msg.id,
@@ -163,7 +171,7 @@ export default function YouTubeChat({ videoId, accessToken, session, onEmotesDis
clearInterval(pollingIntervalRef.current); clearInterval(pollingIntervalRef.current);
} }
}; };
}, [videoId, isConnected]); }, [videoId, accessToken, isConnected]);
// Keep the view pinned to the newest message // Keep the view pinned to the newest message
useEffect(() => { useEffect(() => {

View File

@@ -26,11 +26,17 @@ export const POST: APIRoute = async ({ request }) => {
// Update session credentials for this request // Update session credentials for this request
// This allows us to use an existing accessToken from the client // This allows us to use an existing accessToken from the client
(yt.session as any).signIn({ try {
access_token: accessToken, await (yt.session as any).signIn({
refresh_token: '', // Not needed for a one-off send access_token: accessToken,
expiry_date: new Date(Date.now() + 3600 * 1000).toISOString() expiry_date: Date.now() + 3600 * 1000
}); });
} catch (tokenError) {
return new Response(
JSON.stringify({ error: `Invalid YouTube token: ${tokenError instanceof Error ? tokenError.message : 'Unknown error'}` }),
{ status: 401, headers }
);
}
const info = await yt.getInfo(videoId); const info = await yt.getInfo(videoId);
const liveChat = info.getLiveChat(); const liveChat = info.getLiveChat();
@@ -49,10 +55,16 @@ export const POST: APIRoute = async ({ request }) => {
headers, headers,
}); });
} catch (error) { } catch (error) {
console.error("youtube-chat API error:", error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error("youtube-chat API error:", {
message: errorMessage,
stack: errorStack,
});
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: error instanceof Error ? error.message : "Internal server error", error: errorMessage,
details: process.env.NODE_ENV === 'development' ? errorStack : undefined,
}), }),
{ status: 500, headers }, { status: 500, headers },
); );

View File

@@ -13,6 +13,7 @@ export const GET: APIRoute = async ({ url }) => {
try { try {
const channelUrl = url.searchParams.get("channelUrl"); const channelUrl = url.searchParams.get("channelUrl");
const accessToken = url.searchParams.get("accessToken");
if (!channelUrl) { if (!channelUrl) {
return new Response( return new Response(
@@ -29,29 +30,98 @@ export const GET: APIRoute = async ({ url }) => {
} }
const yt = await getInnertube(); const yt = await getInnertube();
// If accessToken is provided, use it to authenticate the session
if (accessToken) {
try {
await (yt.session as any).signIn({
access_token: accessToken,
expiry_date: Date.now() + 3600 * 1000
});
} catch (tokenError) {
console.error('Failed to authenticate with provided token:', tokenError);
// Continue without authentication - some channels may be public
}
}
// Use getChannel which handles handles, URLs, and IDs // Resolve the URL to get the channel ID/endpoint
const channel = await yt.getChannel(channelUrl); let channelId = channelUrl;
console.log(`[YouTube Live] Looking up channel: ${channelUrl}`);
if (channelUrl.includes('youtube.com') || channelUrl.includes('youtu.be')) {
try {
const endpoint = await (yt as any).resolveURL(channelUrl);
console.log(`[YouTube Live] Resolved URL to endpoint:`, endpoint?.payload);
if (endpoint?.payload?.browseId) {
channelId = endpoint.payload.browseId;
console.log(`[YouTube Live] Using channel ID: ${channelId}`);
}
} catch (urlError) {
console.warn('Failed to resolve URL, will try direct lookup:', urlError);
}
}
// Use getChannel with the resolved ID
const channel = await yt.getChannel(channelId);
console.log(`[YouTube Live] Retrieved channel info`);
// Check if the channel is currently live // Check if the channel is currently live
const liveStreams = await (channel as any).getLiveStreams(); const liveStreams = await (channel as any).getLiveStreams();
const videoId = liveStreams.videos && liveStreams.videos.length > 0 ? liveStreams.videos[0].id : null; console.log(`[YouTube Live] getLiveStreams returned:`, {
hasVideos: !!liveStreams.videos,
videoCount: liveStreams.videos?.length || 0,
});
let videoId = liveStreams.videos && liveStreams.videos.length > 0 ? liveStreams.videos[0].id : null;
// If getLiveStreams didn't find anything, try checking the home tab
if (!videoId) {
console.log(`[YouTube Live] No live streams found via getLiveStreams, trying home feed...`);
try {
const home = await (channel as any).getHome();
if (home?.contents) {
// Look for a live stream section in the home feed
const sections = home.contents.tabContents?.sectionListRenderer?.contents || [];
for (const section of sections) {
const items = section.itemSectionRenderer?.contents || [];
for (const item of items) {
if (item.videoRenderer?.isLiveContent || item.videoRenderer?.badges?.some((b: any) => b.metadataBadgeRenderer?.label === 'LIVE')) {
videoId = item.videoRenderer?.videoId;
if (videoId) {
console.log(`[YouTube Live] Found live video in home feed: ${videoId}`);
break;
}
}
}
if (videoId) break;
}
}
} catch (homeError) {
console.warn('[YouTube Live] Failed to check home feed:', homeError);
}
}
console.log(`[YouTube Live] Final videoId: ${videoId}`);
liveVideoCache.set(channelUrl, { videoId, expires: now + LIVE_CACHE_TTL }); liveVideoCache.set(channelUrl, { videoId, expires: now + LIVE_CACHE_TTL });
// Try to get channel ID from metadata or basic_info // Try to get channel ID from metadata or basic_info
const channelId = (channel as any).basic_info?.id || (channel as any).header?.author?.id || ""; const returnChannelId = (channel as any).basic_info?.id || (channel as any).header?.author?.id || "";
return new Response(JSON.stringify({ videoId, channelId }), { return new Response(JSON.stringify({ videoId, channelId: returnChannelId }), {
status: 200, status: 200,
headers, headers,
}); });
} catch (error) { } catch (error) {
console.error("youtube-live API error:", error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
console.error("youtube-live API error:", {
message: errorMessage,
stack: errorStack,
channelUrl: url.searchParams.get("channelUrl"),
});
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: error instanceof Error ? error.message : "Internal server error", error: errorMessage,
details: process.env.NODE_ENV === 'development' ? errorStack : undefined,
}), }),
{ status: 500, headers }, { status: 500, headers },
); );

View File

@@ -46,6 +46,7 @@ const activeLiveChats = new Map<
export const GET: APIRoute = async ({ url }) => { export const GET: APIRoute = async ({ url }) => {
const videoId = url.searchParams.get('videoId'); const videoId = url.searchParams.get('videoId');
const accessToken = url.searchParams.get('accessToken');
if (!videoId) { if (!videoId) {
return new Response( return new Response(
@@ -66,6 +67,7 @@ export const GET: APIRoute = async ({ url }) => {
const emoteMap = new Map<string, YTEmoteEntry>(); const emoteMap = new Map<string, YTEmoteEntry>();
chat.on('chat-update', (action: any) => { chat.on('chat-update', (action: any) => {
console.log(`[YouTube Chat] Received chat-update action:`, action.type);
if (action.type === 'AddChatItemAction') { if (action.type === 'AddChatItemAction') {
const item = action.item; const item = action.item;
@@ -73,7 +75,7 @@ export const GET: APIRoute = async ({ url }) => {
let chatMsg: ChatMessage | null = null; let chatMsg: ChatMessage | null = null;
if (item.type === 'LiveChatMessage') { if (item.type === 'LiveChatMessage' || item.type === 'LiveChatTextMessage') {
chatMsg = parseLiveChatMessage(item, emoteMap); chatMsg = parseLiveChatMessage(item, emoteMap);
} else if (item.type === 'LiveChatPaidMessageItem') { } else if (item.type === 'LiveChatPaidMessageItem') {
chatMsg = parsePaidMessage(item, emoteMap); chatMsg = parsePaidMessage(item, emoteMap);
@@ -100,6 +102,7 @@ export const GET: APIRoute = async ({ url }) => {
}, 30000); }, 30000);
}); });
console.log(`[YouTube Chat] Starting live chat for video ${videoId}`);
await chat.start(); await chat.start();
// Populate emote map with initial channel emojis // Populate emote map with initial channel emojis
@@ -130,6 +133,10 @@ export const GET: APIRoute = async ({ url }) => {
chatSession.lastUpdate = Date.now(); chatSession.lastUpdate = Date.now();
if (chatSession.messages.length > 0) {
console.log(`[YouTube Chat] Returning ${chatSession.messages.length} messages for video ${videoId}`);
}
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
@@ -147,10 +154,13 @@ export const GET: APIRoute = async ({ url }) => {
} }
); );
} catch (error) { } catch (error) {
console.error('YouTube stream chat API error:', error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
activeLiveChats.delete(videoId);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: error instanceof Error ? error.message : 'Internal server error', error: errorMessage,
details: process.env.NODE_ENV === 'development' ? errorStack : undefined,
}), }),
{ status: 500, headers: { 'Content-Type': 'application/json' } } { status: 500, headers: { 'Content-Type': 'application/json' } }
); );
@@ -197,13 +207,25 @@ function parseLiveChatMessage(msg: any, emoteMap: Map<string, YTEmoteEntry>): Ch
} }
} }
// YouTube timestamps might already be in milliseconds
// Test both formats to see which one matches server time
const rawTimestamp = parseInt(msg.timestamp);
const asMillisecondsDirectly = new Date(rawTimestamp);
const asUsToMs = new Date(rawTimestamp / 1000);
// Use the one that seems more reasonable (closer to now)
const now = new Date();
const msTimeDiff = Math.abs(now.getTime() - asMillisecondsDirectly.getTime());
const usTimeDiff = Math.abs(now.getTime() - asUsToMs.getTime());
const timestamp = msTimeDiff < usTimeDiff ? asMillisecondsDirectly : asUsToMs;
return { return {
id: msg.id, id: msg.id,
author: msg.author.name.toString(), author: msg.author.name.toString(),
authorAvatar: msg.author.thumbnails?.[0]?.url, authorAvatar: msg.author.thumbnails?.[0]?.url,
badges, badges,
parts, parts,
timestamp: new Date(parseInt(msg.timestamp) / 1000) timestamp
}; };
} }