diff --git a/.agents/skills/youtube-ctx/scripts/watch.mjs b/.agents/skills/youtube-ctx/scripts/watch.mjs index 858f597..a641ff5 100755 --- a/.agents/skills/youtube-ctx/scripts/watch.mjs +++ b/.agents/skills/youtube-ctx/scripts/watch.mjs @@ -20652,12 +20652,13 @@ function createYouTubeClient(options = {}) { }); } }; - const player = async (videoId, requireCaptionTrack = true, onFailure) => { + const player = async (videoId, requireCaptionTrack = true) => { if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) { throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); } const attempts = []; let firstResponse; + let playableResponse; for (const profile of PLAYER_PROFILES) { try { const response = await call( @@ -20667,19 +20668,17 @@ function createYouTubeClient(options = {}) { ); firstResponse ??= response; const status = string(object4(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 failure2 = captionAvailabilityError(response); - if (failure2) onFailure?.(failure2); } 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("; ")}`, @@ -20735,11 +20734,8 @@ function createYouTubeClient(options = {}) { if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) { throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); } - const failures = []; const [raw, desktop] = await Promise.all([ - player(videoId, true, (error) => { - failures.push(error); - }), + player(videoId, true), desktopPlayer(videoId) ]); const captions = mergeCaptionCatalog( @@ -20749,7 +20745,7 @@ function createYouTubeClient(options = {}) { if (captions.internal.length === 0) { const primaryError = captionAvailabilityError(raw); const desktopError = desktop.value ? captionAvailabilityError(desktop.value.raw) : desktop.error; - const failure2 = primaryError ?? desktopError ?? failures.find((error) => error.retryable); + const failure2 = primaryError ?? desktopError; if (failure2) throw failure2; } return { raw, captions, captionCookies: desktop.value?.cookies }; @@ -21187,6 +21183,9 @@ function createYouTubeClient(options = {}) { 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 : chooseCaptionTrack(captions.internal, void 0, transcriptOptions.trackId, captions.defaultTrackId)?.track.languageCode); const eligible = captions.internal.filter((track) => transcriptOptions.trackId ? (track.vssId ?? track.languageCode) === transcriptOptions.trackId : !sourceLanguage || track.languageCode === sourceLanguage); if (!eligible.length) { diff --git a/.agents/skills/youtube-ctx/scripts/youtube.mjs b/.agents/skills/youtube-ctx/scripts/youtube.mjs index 1a57155..91823e1 100755 --- a/.agents/skills/youtube-ctx/scripts/youtube.mjs +++ b/.agents/skills/youtube-ctx/scripts/youtube.mjs @@ -20650,12 +20650,13 @@ function createYouTubeClient(options = {}) { }); } }; - const player = async (videoId, requireCaptionTrack = true, onFailure) => { + const player = async (videoId, requireCaptionTrack = true) => { if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) { throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); } const attempts = []; let firstResponse; + let playableResponse; for (const profile of PLAYER_PROFILES) { try { const response = await call( @@ -20665,19 +20666,17 @@ function createYouTubeClient(options = {}) { ); firstResponse ??= response; const status = string(object4(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("; ")}`, @@ -20733,11 +20732,8 @@ function createYouTubeClient(options = {}) { if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) { throw new YouTubeClientError("INVALID_INPUT", "videoId must be 11 characters."); } - const failures = []; const [raw, desktop] = await Promise.all([ - player(videoId, true, (error) => { - failures.push(error); - }), + player(videoId, true), desktopPlayer(videoId) ]); const captions = mergeCaptionCatalog( @@ -20747,7 +20743,7 @@ function createYouTubeClient(options = {}) { 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); + const failure = primaryError ?? desktopError; if (failure) throw failure; } return { raw, captions, captionCookies: desktop.value?.cookies }; @@ -21185,6 +21181,9 @@ function createYouTubeClient(options = {}) { 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 : chooseCaptionTrack(captions.internal, void 0, transcriptOptions.trackId, captions.defaultTrackId)?.track.languageCode); const eligible = captions.internal.filter((track) => transcriptOptions.trackId ? (track.vssId ?? track.languageCode) === transcriptOptions.trackId : !sourceLanguage || track.languageCode === sourceLanguage); if (!eligible.length) { diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 6821ed4..86f7bf4 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -8009,6 +8009,7 @@ "INVALID_INPUT", "INVALID_RESPONSE", "NOT_FOUND", + "CAPTIONS_UNAVAILABLE", "UNAVAILABLE", "UPSTREAM_ERROR", "AUTH_REQUIRED", @@ -9422,6 +9423,7 @@ "INVALID_INPUT", "INVALID_RESPONSE", "NOT_FOUND", + "CAPTIONS_UNAVAILABLE", "UNAVAILABLE", "UPSTREAM_ERROR", "AUTH_REQUIRED", @@ -10251,6 +10253,7 @@ "INVALID_INPUT", "INVALID_RESPONSE", "NOT_FOUND", + "CAPTIONS_UNAVAILABLE", "UNAVAILABLE", "UPSTREAM_ERROR", "AUTH_REQUIRED", diff --git a/packages/all-things-youtube/CHANGELOG.md b/packages/all-things-youtube/CHANGELOG.md index 1fceb4b..f78e38f 100644 --- a/packages/all-things-youtube/CHANGELOG.md +++ b/packages/all-things-youtube/CHANGELOG.md @@ -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 diff --git a/packages/all-things-youtube/package-lock.json b/packages/all-things-youtube/package-lock.json index a1c639e..fecae81 100644 --- a/packages/all-things-youtube/package-lock.json +++ b/packages/all-things-youtube/package-lock.json @@ -1,12 +1,12 @@ { "name": "all-things-youtube", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "all-things-youtube", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { "he": "^1.2.0", diff --git a/packages/all-things-youtube/package.json b/packages/all-things-youtube/package.json index 73cefbd..9a1736b 100644 --- a/packages/all-things-youtube/package.json +++ b/packages/all-things-youtube/package.json @@ -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", diff --git a/packages/all-things-youtube/src/caption-availability.test.ts b/packages/all-things-youtube/src/caption-availability.test.ts index 92a82c2..58554fa 100644 --- a/packages/all-things-youtube/src/caption-availability.test.ts +++ b/packages/all-things-youtube/src/caption-availability.test.ts @@ -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 }); }); }); diff --git a/packages/all-things-youtube/src/youtube-client.ts b/packages/all-things-youtube/src/youtube-client.ts index 704d3d8..c326f59 100644 --- a/packages/all-things-youtube/src/youtube-client.ts +++ b/packages/all-things-youtube/src/youtube-client.ts @@ -1228,12 +1228,13 @@ export function createYouTubeClient(options: YouTubeClientOptions = {}): YouTube } }; - const player = async (videoId: string, requireCaptionTrack = true, onFailure?: (error: YouTubeClientError) => void): Promise => { + const player = async (videoId: string, requireCaptionTrack = true): Promise => { 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( @@ -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('; ')}`, @@ -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); @@ -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 }; @@ -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 diff --git a/packages/all-things-youtube/src/youtube-types.ts b/packages/all-things-youtube/src/youtube-types.ts index 415ec9a..73c079e 100644 --- a/packages/all-things-youtube/src/youtube-types.ts +++ b/packages/all-things-youtube/src/youtube-types.ts @@ -367,6 +367,7 @@ export interface AllCommentOptions { export type YouTubeErrorCode = | 'INVALID_INPUT' | 'NOT_FOUND' + | 'CAPTIONS_UNAVAILABLE' | 'UNAVAILABLE' | 'AUTH_REQUIRED' | 'RATE_LIMITED' diff --git a/platform/package-lock.json b/platform/package-lock.json index 7d913be..596e4a0 100644 --- a/platform/package-lock.json +++ b/platform/package-lock.json @@ -18,7 +18,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", @@ -4255,9 +4255,9 @@ } }, "node_modules/all-things-youtube": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.6.2.tgz", - "integrity": "sha512-71qe9MX8G1h0wGo3UdfNpdZGbIarJhnNFDH1xuH6VjQU5WzMplwWmNZyWJKAL+BoRrBsLeIg8g2xnPnt8DwOUw==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.6.3.tgz", + "integrity": "sha512-tqBdnJm6GwLvG8KOyZOsKhhdr33RdWRgt79TGuHitZY7IvHY45SqinO8zDUVRGgayMqNkxBtpdfooajyDLsL1w==", "license": "MIT", "dependencies": { "he": "^1.2.0", diff --git a/platform/package.json b/platform/package.json index 0563d00..6a9cd9d 100644 --- a/platform/package.json +++ b/platform/package.json @@ -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", diff --git a/platform/src/lib/extraction-diagnostics.ts b/platform/src/lib/extraction-diagnostics.ts index 89b9ec3..7254580 100644 --- a/platform/src/lib/extraction-diagnostics.ts +++ b/platform/src/lib/extraction-diagnostics.ts @@ -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(), diff --git a/platform/src/lib/youtube-processor-client.ts b/platform/src/lib/youtube-processor-client.ts index f43c569..a845c6b 100644 --- a/platform/src/lib/youtube-processor-client.ts +++ b/platform/src/lib/youtube-processor-client.ts @@ -59,6 +59,7 @@ export type YouTubeOperationResult = never; export type ProcessorErrorCode = YouTubeErrorCode + | 'CAPTIONS_UNAVAILABLE' | 'PROCESSOR_BUSY' | 'PROCESSOR_UNAVAILABLE' | 'INVALID_PROCESSOR_RESPONSE' @@ -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; } diff --git a/platform/src/lib/youtube-worker-extraction.ts b/platform/src/lib/youtube-worker-extraction.ts index 92f990e..e807b4b 100644 --- a/platform/src/lib/youtube-worker-extraction.ts +++ b/platform/src/lib/youtube-worker-extraction.ts @@ -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 { @@ -42,11 +42,12 @@ function safeFailure(error: unknown, signal: AbortSignal): YouTubeProcessorError const messages: Record = { 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. */ diff --git a/platform/src/lib/youtube.ts b/platform/src/lib/youtube.ts index 503cba8..cf3c5a7 100644 --- a/platform/src/lib/youtube.ts +++ b/platform/src/lib/youtube.ts @@ -92,7 +92,7 @@ export function withYouTubeMetadata(value: T): T { function processorError(error: {code: string; message: string}, extractionId?: string): ApiError { const status = error.code === 'INVALID_INPUT' ? 422 - : error.code === 'NOT_FOUND' ? 404 + : error.code === 'NOT_FOUND' || error.code === 'CAPTIONS_UNAVAILABLE' ? 404 : error.code === 'AUTH_REQUIRED' ? 401 : error.code === 'RATE_LIMITED' ? 429 : error.code === 'UNAVAILABLE' || error.code === 'PROCESSOR_BUSY' || error.code === 'PROCESSOR_UNAVAILABLE' ? 503 diff --git a/platform/test/e2e/dashboard-api.spec.ts b/platform/test/e2e/dashboard-api.spec.ts index b9483b4..4fbe2b2 100644 --- a/platform/test/e2e/dashboard-api.spec.ts +++ b/platform/test/e2e/dashboard-api.spec.ts @@ -146,15 +146,16 @@ test('recent sources use matching skeleton rows while history is loading', async } finally { await scenario.release(); await scenario.clear(); } }); -for (const theme of ['light', 'dark'] as const) test(`recent URL thumbnails and search icons share dimensions on mobile (${theme})`, async ({ page }, testInfo) => { - await page.setViewportSize({ width: 390, height: 844 }); +for (const mobile of [false, true]) for (const theme of ['light', 'dark'] as const) test(`recent URL thumbnails and search icons share dimensions on ${mobile ? 'mobile' : 'desktop'} (${theme})`, async ({ page }, testInfo) => { + await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1280, height: 800 }); await page.emulateMedia({ colorScheme: theme }); const sources = [ { id: '25c715cb-30f4-4d24-a66f-1cab99d4b4c6', input: `https://youtube.com/watch?v=${videoId}`, title: 'Saved video thumbnail', kind: 'inspection', updatedAt: Date.now(), thumbnailUrl: 'https://thumb.example.test/video.svg' }, { id: '90abdb7b-af0c-429c-9c9d-02949a76d1c6', input: 'Opus vs Astra', title: 'Saved search query', kind: 'search', updatedAt: Date.now() }, ]; await page.route('**/api/platform/v1/sources/recent', route => route.fulfill({ json: { sources } })); - await page.route('https://thumb.example.test/video.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: '' })); + // YouTube's sddefault images are 4:3 with black bars around a 16:9 picture. + await page.route('https://thumb.example.test/video.svg', route => route.fulfill({ contentType: 'image/svg+xml', body: '' })); await page.goto('/dashboard/sources'); const video = page.getByRole('button', { name: /Saved video thumbnail/ }); const search = page.getByRole('button', { name: /Saved search query/ }); @@ -165,6 +166,11 @@ for (const theme of ['light', 'dark'] as const) test(`recent URL thumbnails and expect(videoBounds?.width).toBe(searchBounds?.width); expect(videoBounds?.height).toBe(searchBounds?.height); expect(videoBounds?.width).toBe(64); + expect(videoBounds?.height).toBe(36); + const imageBounds = await video.locator('img').boundingBox(); + expect(imageBounds?.width).toBe(videoBounds?.width); + expect(imageBounds?.height).toBe(videoBounds?.height); + expect(imageBounds?.y).toBe(videoBounds?.y); expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); await page.screenshot({ path: testInfo.outputPath(`recent-sources-${theme}.png`), fullPage: true }); }); @@ -308,6 +314,56 @@ test('metadata renders before a pending transcript and cancel preserves it', asy await expect(page.getByText('Transcript arrived successfully.', { exact: true })).toBeVisible(); }); +test('confirmed missing captions show an empty state without a retry action and survive history restore', async ({ page }, testInfo) => { + const message = 'Captions are not available for this video.'; + const source = { id: 'ad8f901c-11e8-44e2-97cb-9a09b965c455', input: `https://youtu.be/${videoId}`, title: 'Video without captions', kind: 'inspection', updatedAt: Date.now() }; + const snapshot = { kind: 'inspection', inspector: { provider: 'youtube', type: 'video', id: videoId, + data: { id: videoId, title: source.title, thumbnails: [] }, requestedData: ['transcript'], dataErrors: { transcript: message } } }; + await page.route(`**/videos/${videoId}/transcript`, route => route.fulfill({ status: 404, json: { error: { code: 'CAPTIONS_UNAVAILABLE', message } } })); + await page.route('**/api/platform/v1/sources/recent**', route => route.fulfill({ json: route.request().method() === 'POST' + ? { source } : route.request().url().endsWith(source.id) ? { source, snapshot } : { sources: [source] } })); + await page.goto('/dashboard/sources'); + await page.getByRole('textbox', { name: 'Video search or YouTube URL' }).fill(source.input); + await page.getByRole('button', { name: /Inspect/ }).click(); + await expect(page.getByRole('heading', { name: 'No captions available' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry failed requests' })).toHaveCount(0); + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await page.getByRole('button', { name: /Video without captions/ }).click(); + await expect(page.getByRole('heading', { name: 'No captions available' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry failed requests' })).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('missing-captions.png'), fullPage: true }); +}); + +for (const mobile of [false, true]) test(`retrying a transient transcript failure uses skeletons and a styled retry action (${mobile ? 'mobile' : 'desktop'})`, async ({ page }, testInfo) => { + if (mobile) await page.setViewportSize({ width: 390, height: 844 }); + let reads = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + await page.route(`**/videos/${videoId}/transcript`, async route => { + if (++reads === 1) return route.fulfill({ status: 503, json: { error: { code: 'UNAVAILABLE', message: 'YouTube is temporarily unavailable.' } } }); + await gate; + await route.fulfill({ json: transcript }); + }); + try { + await page.goto('/dashboard/sources'); + await page.getByRole('textbox', { name: 'Video search or YouTube URL' }).fill(`https://youtu.be/${videoId}`); + await page.getByRole('button', { name: /Inspect/ }).click(); + const retry = page.getByRole('button', { name: 'Retry failed requests' }); + await expect(retry).toBeVisible(); + await expect(retry).toHaveCSS('border-top-style', 'solid'); + expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); + await page.screenshot({ path: testInfo.outputPath('transcript-retry.png'), fullPage: true }); + await retry.click(); + await expect(page.getByRole('status', { name: 'Loading transcript', exact: true })).toBeVisible(); + await expect(page.getByText('Retrying…', { exact: true })).toHaveCount(0); + await expect(page.getByText('YouTube is temporarily unavailable.', { exact: true })).toHaveCount(0); + expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); + await page.screenshot({ path: testInfo.outputPath('transcript-retrying.png'), fullPage: true }); + release(); + await expect(page.getByText(transcript.text, { exact: true })).toBeVisible(); + } finally { release(); } +}); + for (const hasKeys of [true, false]) { test(`API keys wait for a confirmed ${hasKeys ? 'populated' : 'empty'} response`, async ({ page }, testInfo) => { const scenario = await accountScenario(page, { delays: ['/api/auth/api-key/list'], responses: {'/api/auth/api-key/list': {body: {apiKeys: hasKeys ? [{id:'key-1',name:'Production integration',start:'aty_test',prefix:'aty_',createdAt:'2026-09-22T00:00:00Z',lastRequest:null}]:[],total:hasKeys?1:0}}}}); diff --git a/platform/test/youtube-processor-client.test.ts b/platform/test/youtube-processor-client.test.ts index 38659ed..b3f3afd 100644 --- a/platform/test/youtube-processor-client.test.ts +++ b/platform/test/youtube-processor-client.test.ts @@ -353,6 +353,15 @@ describe('YouTube processor client', () => { expect(requested[0]).not.toBe(requested[1]); }); + test('confirmed caption absence stops fallback immediately', async () => { + const { env, requested } = environment([Response.json({ error: { + code: 'CAPTIONS_UNAVAILABLE', message: 'Captions are not available for this video.', retryable: false, + } }, { status: 404 })]); + await expect(runYouTubeOperation(env, { kind: 'transcript', id: 'abcdefghijk', granularity: 'word' })) + .rejects.toMatchObject({ code: 'CAPTIONS_UNAVAILABLE', status: 404, retryable: false }); + expect(requested).toHaveLength(1); + }); + test('preserves missing captions only after exhausting four calls', async () => { const operation = { kind: 'transcript', id: 'abcdefghijk', granularity: 'word', diff --git a/platform/test/youtube-worker-extraction.test.ts b/platform/test/youtube-worker-extraction.test.ts index 7f25976..91ca723 100644 --- a/platform/test/youtube-worker-extraction.test.ts +++ b/platform/test/youtube-worker-extraction.test.ts @@ -71,6 +71,21 @@ test('an earlier upstream error is not overwritten by a later transcript NOT_FOU await expect(run(env(), operation)).rejects.toMatchObject({ code: 'RATE_LIMITED', status: 429 }); }); +test('confirmed empty caption catalogs survive sanitization and an earlier proxy outage', async () => { + let calls = 0; + const playable = { playabilityStatus: { status: 'OK' }, videoDetails: { videoId: operation.id } }; + const proxyFetch: typeof fetch = async input => String(input).includes('/watch?') + ? new Response(`var ytInitialPlayerResponse = ${JSON.stringify(playable)};`) + : Response.json(playable); + const execute: WorkerExtractionDependencies['execute'] = async (op, fetchImpl) => { + if (++calls === 1) throw unavailable(); + return executeWorkerYouTubeOperation(op, fetchImpl); + }; + const run = createWorkerExtractionRunner({ execute, proxyTransport: () => ({ fetch: proxyFetch, close: async () => {} }) }); + await expect(run(env(), operation)).rejects.toMatchObject({ code: 'CAPTIONS_UNAVAILABLE', retryable: false }); + expect(calls).toBe(2); +}); + test('partial track catalogs visit each distinct route only once', async () => { const partial = { tracks: [], meta: { partial: true } } as unknown as CaptionTrackList; const execute = vi.fn(async () => partial); diff --git a/platform/youtube-frames/package-lock.json b/platform/youtube-frames/package-lock.json index 762d526..0947d44 100644 --- a/platform/youtube-frames/package-lock.json +++ b/platform/youtube-frames/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "2.1.0", - "all-things-youtube": "0.5.0", + "all-things-youtube": "0.6.3", "hono": "4.13.1", "undici": "8.10.0" }, @@ -475,9 +475,9 @@ } }, "node_modules/all-things-youtube": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.5.0.tgz", - "integrity": "sha512-fmLitKYIKKAP2K6CrlhR+0ZdOuElxHwSUxrrZn0FbOZB+8SOwF/YpSjHInc+M9eme/OORmLisEkZXhLGnbMDnQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.6.3.tgz", + "integrity": "sha512-tqBdnJm6GwLvG8KOyZOsKhhdr33RdWRgt79TGuHitZY7IvHY45SqinO8zDUVRGgayMqNkxBtpdfooajyDLsL1w==", "license": "MIT", "dependencies": { "he": "^1.2.0", diff --git a/platform/youtube-frames/package.json b/platform/youtube-frames/package.json index 186f5c2..9f0102f 100644 --- a/platform/youtube-frames/package.json +++ b/platform/youtube-frames/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@hono/node-server": "2.1.0", - "all-things-youtube": "0.5.0", + "all-things-youtube": "0.6.3", "hono": "4.13.1", "undici": "8.10.0" }, diff --git a/platform/youtube-processor/README.md b/platform/youtube-processor/README.md index bb2e321..ffc47a3 100644 --- a/platform/youtube-processor/README.md +++ b/platform/youtube-processor/README.md @@ -29,9 +29,9 @@ Health output includes only `proxyConfigured` and the configured connection coun ### Transcript errors -`all-things-youtube@0.6.2` distinguishes upstream access failures from missing captions. A bot challenge produces retryable `UNAVAILABLE`; upstream throttling remains `RATE_LIMITED`; failed or malformed metadata produces an upstream or invalid-response error. A real login or age restriction produces `AUTH_REQUIRED`. A confirmed playable video without a matching caption track retains `NOT_FOUND`. A usable catalog from either metadata source can recover the extraction even if another source failed. +`all-things-youtube@0.6.3` distinguishes upstream access failures from missing captions. A bot challenge produces retryable `UNAVAILABLE`; upstream throttling remains `RATE_LIMITED`; failed or malformed metadata produces an upstream or invalid-response error. A real login or age restriction produces `AUTH_REQUIRED`. Confirmed playable metadata without any caption tracks produces non-retryable `CAPTIONS_UNAVAILABLE`. A missing requested language or track within an existing caption catalog retains `NOT_FOUND`. A usable catalog from either metadata source can recover the extraction even if another source failed. -The Worker preserves an earlier upstream failure if a later slot reports missing captions. Wrapped agent tool failures retain safe upstream error codes and the extraction ID for correlation with processor attempts. +The Worker preserves an earlier upstream failure if a later slot reports `NOT_FOUND`. Confirmed `CAPTIONS_UNAVAILABLE` is terminal and replaces earlier transient failures. Wrapped agent tool failures retain safe upstream error codes and the extraction ID for correlation with processor attempts. ### Rollout and rollback diff --git a/platform/youtube-processor/app.mjs b/platform/youtube-processor/app.mjs index 20750a4..97d9df2 100644 --- a/platform/youtube-processor/app.mjs +++ b/platform/youtube-processor/app.mjs @@ -24,7 +24,7 @@ function isRecord(value) { function statusForCode(code) { if (code === 'INVALID_INPUT') return 422; - if (code === 'NOT_FOUND') return 404; + if (code === 'NOT_FOUND' || code === 'CAPTIONS_UNAVAILABLE') return 404; if (code === 'AUTH_REQUIRED') return 401; if (code === 'RATE_LIMITED') return 429; if (code === 'UNAVAILABLE') return 503; diff --git a/platform/youtube-processor/package-lock.json b/platform/youtube-processor/package-lock.json index 82442c7..10af015 100644 --- a/platform/youtube-processor/package-lock.json +++ b/platform/youtube-processor/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "2.1.0", - "all-things-youtube": "0.6.2", + "all-things-youtube": "0.6.3", "hono": "4.13.1", "sharp": "0.35.4", "undici": "8.10.0" @@ -986,9 +986,9 @@ } }, "node_modules/all-things-youtube": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.6.2.tgz", - "integrity": "sha512-71qe9MX8G1h0wGo3UdfNpdZGbIarJhnNFDH1xuH6VjQU5WzMplwWmNZyWJKAL+BoRrBsLeIg8g2xnPnt8DwOUw==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/all-things-youtube/-/all-things-youtube-0.6.3.tgz", + "integrity": "sha512-tqBdnJm6GwLvG8KOyZOsKhhdr33RdWRgt79TGuHitZY7IvHY45SqinO8zDUVRGgayMqNkxBtpdfooajyDLsL1w==", "license": "MIT", "dependencies": { "he": "^1.2.0", diff --git a/platform/youtube-processor/package.json b/platform/youtube-processor/package.json index b466e4a..9a43b9c 100644 --- a/platform/youtube-processor/package.json +++ b/platform/youtube-processor/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@hono/node-server": "2.1.0", - "all-things-youtube": "0.6.2", + "all-things-youtube": "0.6.3", "hono": "4.13.1", "undici": "8.10.0", "sharp": "0.35.4" diff --git a/platform/youtube-processor/runtime.mjs b/platform/youtube-processor/runtime.mjs index 8fbd356..7171f89 100644 --- a/platform/youtube-processor/runtime.mjs +++ b/platform/youtube-processor/runtime.mjs @@ -98,7 +98,7 @@ function createConnectionRuntime(proxyUrl) { const record = event => { try { diagnostics.onDiagnostic?.({ ...event, elapsedMs: Date.now() - startedAt }); } catch { /* Best effort. */ } }; - const safeCode = error => ['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'RATE_LIMITED', 'AUTH_REQUIRED'].includes(error?.code) ? error.code : 'UNKNOWN'; + const safeCode = error => ['INVALID_INPUT', 'INVALID_RESPONSE', 'NOT_FOUND', 'CAPTIONS_UNAVAILABLE', 'UNAVAILABLE', 'UPSTREAM_ERROR', 'RATE_LIMITED', 'AUTH_REQUIRED'].includes(error?.code) ? error.code : 'UNKNOWN'; const deadline = AbortSignal.timeout(25_000); try { // Bound each connection attempt so the Worker has time to use another slot. diff --git a/platform/youtube-processor/test/transcript-diagnostics.test.mjs b/platform/youtube-processor/test/transcript-diagnostics.test.mjs index 0aa99b5..1deebf0 100644 --- a/platform/youtube-processor/test/transcript-diagnostics.test.mjs +++ b/platform/youtube-processor/test/transcript-diagnostics.test.mjs @@ -53,3 +53,26 @@ test('published extraction library reports bot-blocked metadata as upstream unav assert.ok(body.diagnostics.events.some(event => event.stage === 'caption_metadata' && event.status === 200)); assert.ok(requests.every(url => url.includes('/player') || url.includes('/watch?'))); }); + +test('published extraction library reports confirmed missing captions as a terminal processor result', async t => { + const requests = []; + const player = { playabilityStatus: { status: 'OK' }, videoDetails: { videoId: 'AR1Gi3RHanE' } }; + t.mock.method(globalThis, 'fetch', async input => { + const url = String(input); + requests.push(url); + return url.includes('/watch?') + ? new Response(`var ytInitialPlayerResponse = ${JSON.stringify(player)};`) + : Response.json(player); + }); + const app = createProcessorApp(createYouTubeRuntime({})); + const response = await app.request('/operations', { method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'transcript', id: 'AR1Gi3RHanE', granularity: 'segment' }) }); + const body = await response.json(); + assert.equal(response.status, 404); + assert.equal(body.error.code, 'CAPTIONS_UNAVAILABLE'); + assert.equal(body.error.retryable, false); + assert.equal(body.diagnostics.events.at(-1).code, 'CAPTIONS_UNAVAILABLE'); + assert.ok(requests.some(url => url.includes('/watch?'))); + assert.ok(requests.some(url => url.includes('/player'))); + assert.equal(body.diagnostics.events.some(event => event.attempt !== undefined), false); +}); diff --git a/web/app/dashboard/DashboardPages.module.css b/web/app/dashboard/DashboardPages.module.css index dc7b43e..e9db904 100644 --- a/web/app/dashboard/DashboardPages.module.css +++ b/web/app/dashboard/DashboardPages.module.css @@ -2,6 +2,18 @@ --page-gutter: 40px; } +.pages :global(.source-operation-loading) { display: flex; align-items: center; gap: 16px; padding-top: 16px; } +.pages :global(.source-operation-loading > .source-skeleton) { flex: 1; margin: 0; } +.pages :global(.source-retry-actions) { padding-block: 16px; } +.pages :global(.source-retry-actions button), .pages :global(.source-operation-loading > button) { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 40px; padding: 8px 14px; border: 1px solid var(--color-dashboard-rule-strong); + border-radius: var(--radius-dashboard-sm); background: var(--color-dashboard-surface); + color: var(--color-dashboard-ink); font: 500 12px/1.5 var(--font-dashboard-body); cursor: pointer; +} +.pages :global(.source-retry-actions button:hover), .pages :global(.source-operation-loading > button:hover) { background: var(--color-dashboard-surface-muted); } +.pages :global(.source-retry-actions button:focus-visible), .pages :global(.source-operation-loading > button:focus-visible) { outline: 2px solid var(--color-dashboard-focus); outline-offset: 3px; } + .pages :global(.source-studio), .pages :global(.source-results), .pages :global(.source-inspector), diff --git a/web/app/dashboard/SourcesClient.tsx b/web/app/dashboard/SourcesClient.tsx index 75f544e..d3aef94 100644 --- a/web/app/dashboard/SourcesClient.tsx +++ b/web/app/dashboard/SourcesClient.tsx @@ -2,9 +2,10 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; +import { ArrowClockwiseIcon } from '@phosphor-icons/react'; import { redirect, useRouter, useSearchParams } from 'next/navigation'; import { platformRequest as api, isAbortError } from '../../lib/platform-request'; -import { loadSourceData, videoIdFromInput } from '../../lib/source-data'; +import { loadSourceData, videoIdFromInput, captionsUnavailable, retryableSourceDatasets } from '../../lib/source-data'; import { Checkbox } from './Checkbox'; import { HistoryEmptyState } from './HistoryEmptyState'; @@ -288,7 +289,7 @@ export default function SourcesClient({ active }: {active:boolean}) { const controller = beginOperation('Retrying failed source requests…'); const next = { ...inspector, dataErrors: { ...inspector.dataErrors } }; try { - await loadVideoData(next, Object.keys(next.dataErrors) as Array, controller); + await loadVideoData(next, retryableSourceDatasets(next.dataErrors), controller); } catch (cause) { if (!isAbortError(cause)) setError(cause instanceof Error ? cause.message : 'The request failed.'); } finally { finishOperation(controller); } @@ -395,7 +396,7 @@ export default function SourcesClient({ active }: {active:boolean}) { {(loading || error || notice) &&
- {loading &&
} + {loading &&
} {error &&
{error}{query.trim() && }
} {notice &&
{notice}
}
} @@ -568,7 +569,7 @@ function InspectorPanel({ inspector, onRetry, onOpenComments, onRefresh, retryin : null} - {Object.keys(inspector.dataErrors).length > 0 ? : null} + {!retrying && retryableSourceDatasets(inspector.dataErrors).length > 0 ?
: null} ; } @@ -650,7 +651,8 @@ function SourceChannelOverview({ channel, fallback, error }: { channel?: Channel } function TranscriptDataPanel({ inspector, segments, transcriptQuery, setTranscriptQuery }: { inspector: Inspector; segments: Segment[]; transcriptQuery: string; setTranscriptQuery: (value: string) => void }) { - if (inspector.loadingData?.includes('transcript') && !inspector.transcript) return ; + if (inspector.loadingData?.includes('transcript') && (!inspector.transcript || inspector.dataErrors.transcript)) return ; + if (captionsUnavailable(inspector.dataErrors.transcript) && !inspector.transcript) return ; if (inspector.dataErrors.transcript && !inspector.transcript) return

{inspector.dataErrors.transcript}

; if (!inspector.transcript) return

No caption track was returned.

; return <> diff --git a/web/app/globals.css b/web/app/globals.css index 773d912..7809499 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1613,13 +1613,14 @@ html:has(.developer-page) body { width: 4rem; aspect-ratio: 16 / 9; display: grid; + position: relative; place-items: center; overflow: hidden; border-radius: var(--radius-dashboard-sm); background: var(--color-dashboard-surface-muted); color: var(--color-dashboard-muted); } -.recent-source-visual img { width: 100%; height: 100%; object-fit: cover; } +.recent-source-visual img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; } .recent-source-visual > * { grid-area: 1 / 1; } .recent-source-visual > span { font-size: .7rem; font-weight: 600; } .recent-source-skeleton { diff --git a/web/lib/source-data.test.ts b/web/lib/source-data.test.ts index c2a4dab..19bb7c0 100644 --- a/web/lib/source-data.test.ts +++ b/web/lib/source-data.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { loadSourceData, videoIdFromInput } from './source-data.ts'; -import { platformRequest } from './platform-request.ts'; +import { loadSourceData, videoIdFromInput, CAPTIONS_UNAVAILABLE_MESSAGE, retryableSourceDatasets } from './source-data.ts'; +import { platformRequest, PlatformApiError } from './platform-request.ts'; for (const [status, code, message] of [ [404, 'NOT_FOUND', 'No transcript found for this video.'], @@ -19,6 +19,14 @@ test('source cancellation is not classified as missing data', async () => { await assert.rejects(loadSourceData(async () => { throw new DOMException('Cancelled', 'AbortError'); }), { name: 'AbortError' }); }); +test('only confirmed caption absence is excluded from failed-request retries', async () => { + const result = await loadSourceData(async () => { throw new PlatformApiError(404, 'CAPTIONS_UNAVAILABLE', 'Provider caption absence'); }); + assert.deepEqual(result, { error: CAPTIONS_UNAVAILABLE_MESSAGE }); + assert.deepEqual(retryableSourceDatasets({ transcript: result.error, metadata: 'Metadata timed out.' }), ['metadata']); + assert.deepEqual(retryableSourceDatasets({ transcript: 'YouTube is temporarily unavailable.' }), ['transcript']); + assert.deepEqual(retryableSourceDatasets({ transcript: 'The requested YouTube resource was not found.' }), ['transcript']); +}); + for (const input of [ '0oXOOlqVu5M', ' https://youtu.be/0oXOOlqVu5M?t=20 ', 'https://www.youtube.com/watch?v=0oXOOlqVu5M&list=PL123', diff --git a/web/lib/source-data.ts b/web/lib/source-data.ts index 157e263..d83a175 100644 --- a/web/lib/source-data.ts +++ b/web/lib/source-data.ts @@ -1,4 +1,15 @@ -import { isAbortError } from './platform-request.ts'; +import { isAbortError, PlatformApiError } from './platform-request.ts'; + +export const CAPTIONS_UNAVAILABLE_MESSAGE = 'Captions are not available for this video.'; + +// Keep the confirmed absence message in history, so restoring it has the same UI. +export function captionsUnavailable(message: string | undefined): boolean { + return message === CAPTIONS_UNAVAILABLE_MESSAGE; +} + +export function retryableSourceDatasets(errors: Partial>) { + return (Object.keys(errors) as Array).filter(dataset => dataset !== 'transcript' || !captionsUnavailable(errors.transcript)); +} export type SourceDataResult = { value: T; error?: never } | { value?: never; error: string }; @@ -7,7 +18,8 @@ export async function loadSourceData(request: () => Promise): Promise