Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions .agents/skills/youtube-ctx/scripts/watch.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 9 additions & 10 deletions .agents/skills/youtube-ctx/scripts/youtube.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/api-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8009,6 +8009,7 @@
"INVALID_INPUT",
"INVALID_RESPONSE",
"NOT_FOUND",
"CAPTIONS_UNAVAILABLE",
"UNAVAILABLE",
"UPSTREAM_ERROR",
"AUTH_REQUIRED",
Expand Down Expand Up @@ -9422,6 +9423,7 @@
"INVALID_INPUT",
"INVALID_RESPONSE",
"NOT_FOUND",
"CAPTIONS_UNAVAILABLE",
"UNAVAILABLE",
"UPSTREAM_ERROR",
"AUTH_REQUIRED",
Expand Down Expand Up @@ -10251,6 +10253,7 @@
"INVALID_INPUT",
"INVALID_RESPONSE",
"NOT_FOUND",
"CAPTIONS_UNAVAILABLE",
"UNAVAILABLE",
"UPSTREAM_ERROR",
"AUTH_REQUIRED",
Expand Down
3 changes: 2 additions & 1 deletion packages/all-things-youtube/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Changelog

## Unreleased
## 0.6.3

- Return non-retryable `CAPTIONS_UNAVAILABLE` when playable metadata confirms that no caption tracks exist. Missing requested languages still return `NOT_FOUND`. Upstream failures remain distinct.
- Add `all-things-youtube/client` as a supported export for `createYouTubeClient`, including browse, video signals, custom fetch transport and shared types.

## 0.6.2
Expand Down
4 changes: 2 additions & 2 deletions packages/all-things-youtube/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/all-things-youtube/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "all-things-youtube",
"version": "0.6.2",
"version": "0.6.3",
"description": "TypeScript toolkit for YouTube transcripts, metadata, storyboards, comments, channels, and playlists.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
12 changes: 11 additions & 1 deletion packages/all-things-youtube/src/caption-availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ describe('caption availability classification', () => {
test('a playable video with confirmed empty catalogs still has missing captions', async () => {
const { client } = fixture(playable);
await expect(client.getTranscript({ videoId: 'AR1Gi3RHanE' }))
.rejects.toMatchObject({ code: 'NOT_FOUND', retryable: false });
.rejects.toMatchObject({ code: 'CAPTIONS_UNAVAILABLE', retryable: false });
});
test('an earlier challenged client does not mask playable empty player and desktop catalogs', async () => {
let playerCalls = 0;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes('/watch?')) return new Response(`var ytInitialPlayerResponse = ${JSON.stringify(playable)};`);
return Response.json(++playerCalls === 1 ? challenged : playable);
});
const client = createYouTubeClient({ fetch, retry: { policy: { maxAttempts: 1 } } });
await expect(client.getTranscript({ videoId: 'AR1Gi3RHanE' }))
.rejects.toMatchObject({ code: 'CAPTIONS_UNAVAILABLE', retryable: false });
});
});
19 changes: 11 additions & 8 deletions packages/all-things-youtube/src/youtube-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,12 +1228,13 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube
}
};

const player = async (videoId: string, requireCaptionTrack = true, onFailure?: (error: YouTubeClientError) => void): Promise<JsonObject> => {
const player = async (videoId: string, requireCaptionTrack = true): Promise<JsonObject> => {
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
throw new YouTubeClientError('INVALID_INPUT', 'videoId must be 11 characters.');
}
const attempts: string[] = [];
let firstResponse: JsonObject | undefined;
let playableResponse: JsonObject | undefined;
for (const profile of PLAYER_PROFILES) {
try {
const response = await call(
Expand All @@ -1243,19 +1244,17 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube
);
firstResponse ??= response;
const status = string(object(response.playabilityStatus).status);
if (status === 'OK') playableResponse ??= response;
const tracks = parseCaptionTracks(response).internal;
if (status === 'OK' && (!requireCaptionTrack || tracks.some(track => captionUrl(track.baseUrl)))) return response;
attempts.push(`${profile.name}: ${status ?? 'UNKNOWN'}`);
const failure = captionAvailabilityError(response);
if (failure) onFailure?.(failure);
} catch (error) {
if (error instanceof YouTubeClientError) onFailure?.(error);
attempts.push(
`${profile.name}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
if (firstResponse) return firstResponse;
if (playableResponse || firstResponse) return playableResponse ?? firstResponse!;
throw new YouTubeClientError(
'UNAVAILABLE',
`Video unavailable on every client. ${attempts.join('; ')}`,
Expand Down Expand Up @@ -1312,9 +1311,8 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
throw new YouTubeClientError('INVALID_INPUT', 'videoId must be 11 characters.');
}
const failures: YouTubeClientError[] = [];
const [raw, desktop] = await Promise.all([
player(videoId, true, error => { failures.push(error); }), desktopPlayer(videoId),
player(videoId, true), desktopPlayer(videoId),
]);
const captions = mergeCaptionCatalog(parseCaptionTracks(raw),
desktop.value ? parseCaptionTracks(desktop.value.raw) : undefined);
Expand All @@ -1323,7 +1321,9 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube
if (captions.internal.length === 0) {
const primaryError = captionAvailabilityError(raw);
const desktopError = desktop.value ? captionAvailabilityError(desktop.value.raw) : desktop.error;
const failure = primaryError ?? desktopError ?? failures.find(error => error.retryable);
// Two playable empty catalogs are affirmative evidence. An earlier failed
// client must not override those later successful metadata checks.
const failure = primaryError ?? desktopError;
if (failure) throw failure;
}
return { raw, captions, captionCookies: desktop.value?.cookies };
Expand Down Expand Up @@ -1812,6 +1812,9 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube
const requestedTranslation = transcriptOptions.translateTo?.trim();
const prepareRequest = async () => {
const { captions, captionCookies } = await playerWithCaptionCatalog(transcriptOptions.videoId);
if (!captions.internal.length) {
throw new YouTubeClientError('CAPTIONS_UNAVAILABLE', 'Captions are not available for this video.');
}
const sourceLanguage = transcriptOptions.language ??
(requestedTranslation && captions.internal.some(track => track.languageCode === requestedTranslation)
? requestedTranslation
Expand Down
1 change: 1 addition & 0 deletions packages/all-things-youtube/src/youtube-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ export interface AllCommentOptions {
export type YouTubeErrorCode =
| 'INVALID_INPUT'
| 'NOT_FOUND'
| 'CAPTIONS_UNAVAILABLE'
| 'UNAVAILABLE'
| 'AUTH_REQUIRED'
| 'RATE_LIMITED'
Expand Down
8 changes: 4 additions & 4 deletions platform/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"@upstash/redis": "^1.38.2",
"agents": "0.21.0",
"ai": "7.0.77",
"all-things-youtube": "0.6.2",
"all-things-youtube": "0.6.3",
"better-auth": "1.6.26",
"better-call": "1.3.7",
"he": "^1.2.0",
Expand Down
2 changes: 1 addition & 1 deletion platform/src/lib/extraction-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const extractionEventSchema = z.object({
outcome: z.enum(['selected', 'skipped', 'error', 'success']).optional(),
playabilityStatus: z.enum(['OK', 'LOGIN_REQUIRED', 'UNPLAYABLE', 'ERROR', 'LIVE_STREAM_OFFLINE', 'CONTENT_CHECK_REQUIRED', 'AGE_CHECK_REQUIRED', 'UNKNOWN']).optional(),
specState: z.enum(['valid', 'missing', 'malformed']).optional(),
code: z.enum(['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'AUTH_REQUIRED',
code: z.enum(['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'CAPTIONS_UNAVAILABLE', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'AUTH_REQUIRED',
'RATE_LIMITED', 'FRAME_EXTRACTION_FAILED', 'FRAME_TIMEOUT', 'FRAME_CANCELLED', 'MEDIA_UNAVAILABLE', 'UNKNOWN']).optional(),
inputFormat: z.enum(['webp', 'jpeg']).optional(), outputFormat: z.enum(['webp', 'jpeg']).optional(),
status: z.number().int().min(100).max(599).optional(), elapsedMs: metric.optional(),
Expand Down
3 changes: 2 additions & 1 deletion platform/src/lib/youtube-processor-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type YouTubeOperationResult<T extends YouTubeOperation> =
never;

export type ProcessorErrorCode = YouTubeErrorCode
| 'CAPTIONS_UNAVAILABLE'
| 'PROCESSOR_BUSY'
| 'PROCESSOR_UNAVAILABLE'
| 'INVALID_PROCESSOR_RESPONSE'
Expand Down Expand Up @@ -187,7 +188,7 @@ export function shouldFallbackResult(operation: YouTubeOperation, result: unknow
export function shouldFallbackError(operation: YouTubeOperation, error: YouTubeProcessorError): boolean {
// Upstream transcript error labels are not reliable proof of permanent failure.
// Retry across the pool, except invalid input or a confirmed access restriction.
if (operation.kind === 'transcript') return error.code !== 'INVALID_INPUT' && error.code !== 'AUTH_REQUIRED';
if (operation.kind === 'transcript') return !['INVALID_INPUT', 'AUTH_REQUIRED', 'CAPTIONS_UNAVAILABLE'].includes(error.code);
return error.retryable;
}

Expand Down
5 changes: 3 additions & 2 deletions platform/src/lib/youtube-worker-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isVideoMetadataBotChallenge } from './youtube-metadata';

const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_ATTEMPT_BYTES = 32 * 1024 * 1024;
const SAFE_CODES = ['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'RATE_LIMITED', 'AUTH_REQUIRED'] as const;
const SAFE_CODES = ['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'CAPTIONS_UNAVAILABLE', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'RATE_LIMITED', 'AUTH_REQUIRED'] as const;
type SafeCode = typeof SAFE_CODES[number];

function bounded(value: string | undefined, fallback: number, min: number, max: number): number {
Expand Down Expand Up @@ -42,11 +42,12 @@ function safeFailure(error: unknown, signal: AbortSignal): YouTubeProcessorError
const messages: Record<SafeCode, string> = {
INVALID_INPUT: 'The YouTube request is invalid.', INVALID_RESPONSE: 'YouTube returned an unusable response.',
NOT_FOUND: 'The requested YouTube resource was not found.', UNAVAILABLE: 'YouTube is temporarily unavailable.',
CAPTIONS_UNAVAILABLE: 'Captions are not available for this video.',
UPSTREAM_ERROR: 'The YouTube request failed.', RATE_LIMITED: 'YouTube rate limited the request.',
AUTH_REQUIRED: 'YouTube requires authorization for this resource.',
};
const status = typeof value?.status === 'number' && Number.isInteger(value.status) && value.status >= 100 && value.status <= 599 ? value.status : undefined;
return new YouTubeProcessorError(code, messages[code], status, !['INVALID_INPUT', 'AUTH_REQUIRED'].includes(code) && (value?.retryable === true || ['RATE_LIMITED', 'UPSTREAM_ERROR', 'INVALID_RESPONSE', 'UNAVAILABLE'].includes(code)));
return new YouTubeProcessorError(code, messages[code], status, !['INVALID_INPUT', 'AUTH_REQUIRED', 'CAPTIONS_UNAVAILABLE'].includes(code) && (value?.retryable === true || ['RATE_LIMITED', 'UPSTREAM_ERROR', 'INVALID_RESPONSE', 'UNAVAILABLE'].includes(code)));
}

/** Stop waiting even if an underlying adapter does not implement cancellation. */
Expand Down
Loading
Loading