Skip to content

Commit 66f1129

Browse files
devhimsclaude
andcommitted
sample: surface YouTube datacenter-IP block as a friendly notice
Vercel functions hit YouTube's bot challenge because Vercel runs on AWS Lambda — datacenter IPs are gated regardless of client choice. In v1.10.0 the library throws an informative error in this case (rather than the silent empty-array of older versions), which surfaced on the deployed demo as a generic 500. This commit improves the demo's failure mode: - New shared handleApiError() helper detects the bot-challenge family of errors (LOGIN_REQUIRED, "not a bot", "no longer supported", "Video not playable on any client") and returns a structured 503 response with { code: 'youtube_blocked_datacenter_ip', message, debug }. - Real failures (network errors, etc.) still return 500 with code: 'unknown_error' so they remain distinguishable. - Both API routes (subtitles, videoDetails) now use the shared helper. - Frontend reads the response body for { code, message } instead of just the HTTP status, and renders the datacenter-IP case as a calm amber notice ("Live demo limitation") with explainer links to the README, instead of treating it as a bug. - Real errors keep the existing inline red-bordered treatment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1892d1d commit 66f1129

5 files changed

Lines changed: 118 additions & 28 deletions

File tree

sample/app/api/_lib/handleError.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse } from 'next/server';
2+
3+
/**
4+
* Maps library errors to HTTP responses. YouTube blocks datacenter IPs
5+
* (Vercel, AWS Lambda, Cloudflare Workers) with a bot challenge — the
6+
* library will throw a descriptive "Video not playable on any client"
7+
* error in that case. We surface that as a 503 with a friendly explanation
8+
* instead of a generic 500.
9+
*/
10+
export function handleApiError(error: unknown): NextResponse {
11+
const message = error instanceof Error ? error.message : String(error);
12+
13+
const looksLikeBotChallenge =
14+
message.includes('LOGIN_REQUIRED') ||
15+
message.includes('not a bot') ||
16+
message.includes('no longer supported') ||
17+
message.includes('Video not playable on any client');
18+
19+
if (looksLikeBotChallenge) {
20+
return NextResponse.json(
21+
{
22+
code: 'youtube_blocked_datacenter_ip',
23+
message:
24+
'YouTube is blocking this server. Most cloud hosts (Vercel, AWS Lambda, Cloudflare Workers) share IP ranges that YouTube gates with a bot challenge — no client-side fix can bypass it. The library works on residential IPs: run the demo locally to see it in action, or wire up a residential proxy via the `fetch` option.',
25+
debug: message,
26+
},
27+
{ status: 503 }
28+
);
29+
}
30+
31+
return NextResponse.json(
32+
{ code: 'unknown_error', message },
33+
{ status: 500 }
34+
);
35+
}

sample/app/api/subtitles/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { getSubtitles } from 'youtube-caption-extractor';
2-
import { NextResponse } from 'next/server';
3-
import { type NextRequest } from 'next/server';
2+
import { NextResponse, type NextRequest } from 'next/server';
3+
import { handleApiError } from '../_lib/handleError';
44

55
export async function GET(request: NextRequest) {
66
const searchParams = request.nextUrl.searchParams;
77
const videoID = searchParams.get('videoID');
88
const lang = searchParams.get('lang') || 'en';
99

1010
if (!videoID) {
11-
return NextResponse.json({ error: 'Missing videoID' }, { status: 400 });
11+
return NextResponse.json(
12+
{ code: 'missing_video_id', message: 'Missing videoID' },
13+
{ status: 400 }
14+
);
1215
}
1316

1417
try {
1518
const subtitles = await getSubtitles({ videoID, lang });
1619
return NextResponse.json({ subtitles }, { status: 200 });
1720
} catch (error) {
18-
return NextResponse.json(
19-
{ error: (error as Error).message },
20-
{ status: 500 }
21-
);
21+
return handleApiError(error);
2222
}
2323
}
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { getVideoDetails } from 'youtube-caption-extractor';
2-
import { NextResponse } from 'next/server';
3-
import { type NextRequest } from 'next/server';
2+
import { NextResponse, type NextRequest } from 'next/server';
3+
import { handleApiError } from '../_lib/handleError';
44

55
export async function GET(request: NextRequest) {
66
const searchParams = request.nextUrl.searchParams;
77
const videoID = searchParams.get('videoID');
88
const lang = searchParams.get('lang') || 'en';
99

1010
if (!videoID) {
11-
return NextResponse.json({ error: 'Missing videoID' }, { status: 400 });
11+
return NextResponse.json(
12+
{ code: 'missing_video_id', message: 'Missing videoID' },
13+
{ status: 400 }
14+
);
1215
}
1316

1417
try {
1518
const videoDetails = await getVideoDetails({ videoID, lang });
1619
return NextResponse.json({ videoDetails }, { status: 200 });
1720
} catch (error) {
18-
return NextResponse.json(
19-
{ error: (error as Error).message },
20-
{ status: 500 }
21-
);
21+
return handleApiError(error);
2222
}
2323
}

sample/app/page.tsx

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import { useEffect, useMemo, useRef, useState } from 'react';
44

55
type Subtitle = { start: string; dur: string; text: string };
66
type VideoDetails = { title?: string; description?: string };
7+
type ErrorState = { message: string; code?: string };
8+
9+
async function readApiError(res: Response): Promise<ErrorState> {
10+
try {
11+
const body = await res.json();
12+
return {
13+
message: body.message ?? body.error ?? `HTTP ${res.status}`,
14+
code: body.code,
15+
};
16+
} catch {
17+
return { message: `HTTP ${res.status}` };
18+
}
19+
}
720

821
const YOUTUBE_ID_REGEX =
922
/(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/;
@@ -81,7 +94,7 @@ export default function HomePage() {
8194
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
8295
const [videoDetails, setVideoDetails] = useState<VideoDetails>({});
8396
const [videoId, setVideoId] = useState('');
84-
const [error, setError] = useState<string | null>(null);
97+
const [error, setError] = useState<ErrorState | null>(null);
8598
const [isFetching, setIsFetching] = useState(false);
8699
const [query, setQuery] = useState('');
87100
const [copied, setCopied] = useState(false);
@@ -127,7 +140,9 @@ export default function HomePage() {
127140
const raw = overrideInput ?? input;
128141
const id = extractVideoId(raw);
129142
if (!id || id.length !== 11) {
130-
setError('That doesn’t look like a YouTube URL or video ID.');
143+
setError({
144+
message: 'That doesn’t look like a YouTube URL or video ID.',
145+
});
131146
return;
132147
}
133148
if (overrideInput) setInput(overrideInput);
@@ -139,22 +154,33 @@ export default function HomePage() {
139154
fetch(`/api/subtitles?videoID=${id}&lang=${lang}`),
140155
fetch(`/api/videoDetails?videoID=${id}&lang=${lang}`),
141156
]);
142-
if (!subsRes.ok) throw new Error(`Subtitles API: HTTP ${subsRes.status}`);
143-
if (!detailsRes.ok)
144-
throw new Error(`Details API: HTTP ${detailsRes.status}`);
157+
if (!subsRes.ok) {
158+
setError(await readApiError(subsRes));
159+
setSubtitles([]);
160+
setVideoDetails({});
161+
return;
162+
}
163+
if (!detailsRes.ok) {
164+
setError(await readApiError(detailsRes));
165+
setSubtitles([]);
166+
setVideoDetails({});
167+
return;
168+
}
145169
const subsData = await subsRes.json();
146170
const detailsData = await detailsRes.json();
147171
const subs: Subtitle[] = subsData.subtitles ?? [];
148172
setSubtitles(subs);
149173
setVideoDetails(detailsData.videoDetails ?? {});
150174
setVideoId(id);
151175
if (subs.length === 0) {
152-
setError(
153-
`No captions for ${LANGUAGES.find((l) => l[0] === lang)?.[1] ?? lang}. This video might not be subtitled in that language.`
154-
);
176+
setError({
177+
message: `No captions for ${LANGUAGES.find((l) => l[0] === lang)?.[1] ?? lang}. This video might not be subtitled in that language.`,
178+
});
155179
}
156180
} catch (err) {
157-
setError(err instanceof Error ? err.message : 'Unknown error');
181+
setError({
182+
message: err instanceof Error ? err.message : 'Unknown error',
183+
});
158184
setSubtitles([]);
159185
setVideoDetails({});
160186
} finally {
@@ -286,11 +312,40 @@ export default function HomePage() {
286312
</div>
287313
</form>
288314

289-
{/* Error */}
315+
{/* Error / notice */}
290316
{error && (
291-
<p className='mt-6 text-sm text-stone-600 border-l-2 border-stone-300 pl-3'>
292-
{error}
293-
</p>
317+
error.code === 'youtube_blocked_datacenter_ip' ? (
318+
<aside className='mt-8 rounded-md border border-amber-200 bg-amber-50/60 px-5 py-4'>
319+
<div className='font-mono text-[10px] uppercase tracking-[0.22em] text-amber-700 mb-2'>
320+
Live demo limitation
321+
</div>
322+
<p className='text-sm text-stone-700 leading-relaxed'>
323+
{error.message}
324+
</p>
325+
<div className='mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs'>
326+
<a
327+
href='https://github.com/devhims/youtube-caption-extractor#deployment-environments'
328+
target='_blank'
329+
rel='noopener noreferrer'
330+
className='text-amber-800 hover:text-amber-900 underline decoration-amber-300 hover:decoration-amber-700 decoration-1 underline-offset-[3px] transition-colors'
331+
>
332+
why this happens
333+
</a>
334+
<a
335+
href='https://github.com/devhims/youtube-caption-extractor'
336+
target='_blank'
337+
rel='noopener noreferrer'
338+
className='text-amber-800 hover:text-amber-900 underline decoration-amber-300 hover:decoration-amber-700 decoration-1 underline-offset-[3px] transition-colors'
339+
>
340+
run locally
341+
</a>
342+
</div>
343+
</aside>
344+
) : (
345+
<p className='mt-6 text-sm text-stone-600 border-l-2 border-stone-300 pl-3'>
346+
{error.message}
347+
</p>
348+
)
294349
)}
295350

296351
{/* Loading */}

sample/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0 commit comments

Comments
 (0)