Skip to content
Open
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
54 changes: 52 additions & 2 deletions src/hooks/useSubtitles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
);

Expand All @@ -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() }
);

Expand All @@ -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);
});
});
13 changes: 11 additions & 2 deletions src/hooks/useSubtitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand All @@ -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')) {
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/useVideoProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useVideoProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
64 changes: 64 additions & 0 deletions src/lib/sentry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
99 changes: 99 additions & 0 deletions src/lib/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
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
Expand Down Expand Up @@ -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)
Expand Down
Loading