diff --git a/src/hooks/useSubtitles.test.ts b/src/hooks/useSubtitles.test.ts index ebdb9f16..a7ddb2e6 100644 --- a/src/hooks/useSubtitles.test.ts +++ b/src/hooks/useSubtitles.test.ts @@ -81,7 +81,10 @@ describe('useSubtitles protected media auth', () => { global.fetch = fetchSpy as typeof fetch; const { result } = renderHook( - () => useSubtitles(makeVideo({ ageRestricted: true })), + () => useSubtitles(makeVideo({ + ageRestricted: true, + textTrackRef: `39307:${'a'.repeat(64)}:subtitles:video-1`, + })), { wrapper: createWrapper() } ); @@ -107,7 +110,10 @@ describe('useSubtitles protected media auth', () => { global.fetch = fetchSpy as typeof fetch; const { result } = renderHook( - () => useSubtitles(makeVideo({ ageRestricted: true })), + () => useSubtitles(makeVideo({ + ageRestricted: true, + textTrackRef: `39307:${'a'.repeat(64)}:subtitles:video-1`, + })), { wrapper: createWrapper() } ); @@ -118,4 +124,48 @@ describe('useSubtitles protected media auth', () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(result.current.hasSubtitles).toBe(false); }); + + it('does not probe CDN subtitles when video does not advertise text tracks', async () => { + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as typeof fetch; + + const { result } = renderHook( + () => useSubtitles(makeVideo({ ageRestricted: false, textTrackRef: undefined })), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockNostrQuery).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.current.hasSubtitles).toBe(false); + }); + + it('treats CDN 422 subtitle responses as benign missing subtitles', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: false, + status: 422, + text: () => Promise.resolve(''), + }); + global.fetch = fetchSpy as typeof fetch; + + const { result } = renderHook( + () => useSubtitles(makeVideo({ + textTrackRef: `39307:${'a'.repeat(64)}:subtitles:video-1`, + })), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://media.divine.video/4a31d696c2275e60dbfe2359e6ff006f78a30f5df11c7290233a7860c4e8c31e/vtt', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(result.current.hasSubtitles).toBe(false); + }); }); diff --git a/src/hooks/useSubtitles.ts b/src/hooks/useSubtitles.ts index 96b0c97f..e07c9c37 100644 --- a/src/hooks/useSubtitles.ts +++ b/src/hooks/useSubtitles.ts @@ -68,7 +68,9 @@ export function useSubtitles( const hasRef = !!video?.textTrackRef; const cdnHash = video?.videoUrl ? extractCdnHash(video.videoUrl) : null; const requiresProtectedCdnAuth = !!video?.ageRestricted && !!video?.videoUrl && isProtectedDivineMediaUrl(video.videoUrl); - const queryEnabled = !!video && (hasEmbeddedContent || hasRef || !!cdnHash); + // Only attempt subtitle fetch when video explicitly advertises text tracks. + // This avoids blind CDN /vtt probes for videos that never declared subtitles. + const queryEnabled = !!video && (hasEmbeddedContent || hasRef); const { data: cues = [], isLoading } = useQuery({ queryKey: ['subtitles', video?.id, video?.textTrackRef, cdnHash, requiresProtectedCdnAuth, isAdultVerified], @@ -103,7 +105,9 @@ export function useSubtitles( } // Tier 3: Fetch VTT from CDN (media.divine.video/{hash}/vtt) - if (cdnHash) { + // only when a text-track ref exists. This remains a fallback path, not + // a blind probe for all media blobs. + if (cdnHash && video.textTrackRef) { try { const vttUrl = `https://media.divine.video/${cdnHash}/vtt`; const response = requiresProtectedCdnAuth @@ -124,6 +128,11 @@ export function useSubtitles( return []; } + // Optional subtitle assets can legitimately miss on CDN. + if (response.status === 404 || response.status === 410 || response.status === 422) { + return []; + } + if (response.ok) { const text = await response.text(); if (text.trim().startsWith('WEBVTT')) { diff --git a/src/hooks/useVideoProvider.test.ts b/src/hooks/useVideoProvider.test.ts index c76cc278..5a774a1f 100644 --- a/src/hooks/useVideoProvider.test.ts +++ b/src/hooks/useVideoProvider.test.ts @@ -153,6 +153,21 @@ describe('canServeFeedViaWebsocket', () => { }); describe('useVideoProvider', () => { + it('uses a bounded classics randomization window to avoid high-offset timeouts', () => { + renderHook(() => + useVideoProvider({ + feedType: 'classics', + }) + ); + + expect(mockUseInfiniteVideosFunnelcake).toHaveBeenCalledWith(expect.objectContaining({ + feedType: 'classics', + apiUrl: 'https://api.divine.video', + randomizeWithinTop: 120, + enabled: true, + })); + }); + it('routes category feeds away from the WebSocket hook', () => { relayUrl = 'wss://relay.damus.io'; mockUseResolvedRelayCapabilities.mockReturnValue(makeCapabilities({ diff --git a/src/hooks/useVideoProvider.ts b/src/hooks/useVideoProvider.ts index a1c580e1..7f4731bd 100644 --- a/src/hooks/useVideoProvider.ts +++ b/src/hooks/useVideoProvider.ts @@ -14,6 +14,7 @@ import type { SortMode } from '@/types/nostr'; // Feed types that can be provided export type VideoFeedType = 'discovery' | 'home' | 'trending' | 'hashtag' | 'profile' | 'recent' | 'classics' | 'foryou' | 'category'; type WebsocketVideoFeedType = 'discovery' | 'home' | 'trending' | 'hashtag' | 'profile' | 'recent'; +const CLASSICS_RANDOMIZATION_WINDOW = 120; interface UseVideoProviderOptions { feedType: VideoFeedType; @@ -238,7 +239,9 @@ export function useVideoProvider({ pubkey, pageSize, enabled: enabled && shouldUseFunnelcake, - randomizeWithinTop: feedType === 'classics' ? 500 : undefined, + // Keep Classics in a low, stable offset window. + // Higher offsets on loops+classic queries are currently prone to backend timeouts. + randomizeWithinTop: feedType === 'classics' ? CLASSICS_RANDOMIZATION_WINDOW : undefined, }); const shouldEnableWebsocket = diff --git a/src/lib/sentry.test.ts b/src/lib/sentry.test.ts new file mode 100644 index 00000000..d48b2946 --- /dev/null +++ b/src/lib/sentry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { shouldDropBenignSubtitleVttEvent } from './sentry'; + +describe('shouldDropBenignSubtitleVttEvent', () => { + it('drops media subtitle /vtt 422 events', () => { + const event = { + request: { + url: 'https://media.divine.video/f423713bd22dc6ff6e28cd1767a13a85cd4013397a592dbc55060808cf84824c/vtt', + }, + contexts: { + response: { + status_code: 422, + }, + }, + }; + + expect(shouldDropBenignSubtitleVttEvent(event)).toBe(true); + }); + + it('drops media subtitle /vtt 404 events', () => { + const event = { + request: { + url: 'https://media.divine.video/example-hash/vtt', + }, + contexts: { + response: { + status_code: '404', + }, + }, + }; + + expect(shouldDropBenignSubtitleVttEvent(event)).toBe(true); + }); + + it('keeps non-benign /vtt errors', () => { + const event = { + request: { + url: 'https://media.divine.video/example-hash/vtt', + }, + contexts: { + response: { + status_code: 500, + }, + }, + }; + + expect(shouldDropBenignSubtitleVttEvent(event)).toBe(false); + }); + + it('keeps 422 errors for non-subtitle URLs', () => { + const event = { + request: { + url: 'https://api.divine.video/api/videos', + }, + contexts: { + response: { + status_code: 422, + }, + }, + }; + + expect(shouldDropBenignSubtitleVttEvent(event)).toBe(false); + }); +}); diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index e9fb92e8..68b6d742 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -3,6 +3,101 @@ import * as Sentry from '@sentry/react'; +const BENIGN_SUBTITLE_VTT_STATUS_CODES = new Set([404, 410, 422]); + +function toObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null) { + return value as Record; + } + return null; +} + +function toStatusCode(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function looksLikeSubtitleVttUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname === 'media.divine.video' && parsed.pathname.endsWith('/vtt'); + } catch { + return url.includes('media.divine.video') && /\/vtt(?:$|\?|#)/.test(url); + } +} + +function extractStatusFromMessage(text: string): number | null { + const match = text.match(/\b(4\d{2}|5\d{2})\b/); + if (!match) return null; + return Number.parseInt(match[1], 10); +} + +function extractHttpStatusCode(event: unknown): number | null { + const eventObject = toObject(event); + if (!eventObject) return null; + + const contexts = toObject(eventObject.contexts); + const responseContext = contexts ? toObject(contexts.response) : null; + const contextStatus = responseContext ? toStatusCode(responseContext.status_code) : null; + if (contextStatus !== null) return contextStatus; + + const tags = toObject(eventObject.tags); + const tagStatus = tags + ? toStatusCode(tags['http.status_code'] ?? tags.status_code) + : null; + if (tagStatus !== null) return tagStatus; + + const extra = toObject(eventObject.extra); + const extraStatus = extra + ? toStatusCode(extra.status_code ?? extra.status ?? extra['http.status_code']) + : null; + if (extraStatus !== null) return extraStatus; + + const message = typeof eventObject.message === 'string' ? eventObject.message : ''; + const exception = toObject(eventObject.exception); + const values = Array.isArray(exception?.values) ? exception?.values : []; + const exceptionText = values + .map((value) => { + const obj = toObject(value); + return typeof obj?.value === 'string' ? obj.value : ''; + }) + .join(' '); + + return extractStatusFromMessage(`${message} ${exceptionText}`); +} + +function extractEventUrl(event: unknown): string | null { + const eventObject = toObject(event); + if (!eventObject) return null; + + const request = toObject(eventObject.request); + if (request && typeof request.url === 'string') { + return request.url; + } + + const message = typeof eventObject.message === 'string' ? eventObject.message : ''; + const urlMatch = message.match(/https?:\/\/\S+/); + return urlMatch?.[0] ?? null; +} + +export function shouldDropBenignSubtitleVttEvent(event: unknown): boolean { + const url = extractEventUrl(event); + if (!url || !looksLikeSubtitleVttUrl(url)) return false; + + const statusCode = extractHttpStatusCode(event); + if (statusCode === null) return false; + + return BENIGN_SUBTITLE_VTT_STATUS_CODES.has(statusCode); +} + /** * Initialize Sentry error tracking * Call this as early as possible in the app lifecycle @@ -105,6 +200,10 @@ export function initializeSentry() { // Don't send PII beforeSend(event) { + if (shouldDropBenignSubtitleVttEvent(event)) { + return null; + } + // Scrub any potential PII from the event if (event.user) { // Only keep anonymized user ID (pubkey is already pseudonymous)