From e340eb4dcc186bc704d105d850b0aae2281e9a26 Mon Sep 17 00:00:00 2001 From: threehappypenguins Date: Sun, 5 Jul 2026 20:13:41 -0300 Subject: [PATCH 1/3] feat(youtube-import): fallback to embed preview when no browser-streamable MP4 exists --- __tests__/api/youtube-import/resolve.test.ts | 37 +++ .../youtube-import/TrimRangeSlider.test.tsx | 28 +- .../YouTubeEmbedPreviewPlayer.test.tsx | 89 +++++++ .../YouTubeImportModal.test.tsx | 67 ++++- .../youtube-import/probe-keyframes.test.ts | 46 ++++ app/api/youtube-import/resolve/route.ts | 12 + components/youtube-import/TrimRangeSlider.tsx | 60 +++-- .../YouTubeEmbedPreviewPlayer.tsx | 244 ++++++++++++++++++ .../youtube-import/YouTubeImportModal.tsx | 69 ++--- lib/youtube-import/probe-keyframes.ts | 35 ++- lib/youtube-import/resolve-source.ts | 30 ++- 11 files changed, 632 insertions(+), 85 deletions(-) create mode 100644 __tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx create mode 100644 components/youtube-import/YouTubeEmbedPreviewPlayer.tsx diff --git a/__tests__/api/youtube-import/resolve.test.ts b/__tests__/api/youtube-import/resolve.test.ts index f6485a1a..807d2afa 100644 --- a/__tests__/api/youtube-import/resolve.test.ts +++ b/__tests__/api/youtube-import/resolve.test.ts @@ -40,6 +40,7 @@ vi.mock('@/lib/youtube-import/preview-media-url', () => ({ })); import { POST } from '@/app/api/youtube-import/resolve/route'; +import { NoBrowserStreamableFormatError } from '@/lib/youtube-import/probe-keyframes'; const USER_ID = 'user-123'; const ACCESS_TOKEN = 'yt-access-token'; @@ -134,6 +135,7 @@ describe('POST /api/youtube-import/resolve', () => { const body = await response.json(); expect(body.data).toEqual({ ...resolvedMetadata, + previewMode: 'direct', previewStreamUrl: '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ', previewExpiresAt: expect.any(Number), }); @@ -200,6 +202,41 @@ describe('POST /api/youtube-import/resolve', () => { expect(body.message).toBe('quotaExceeded'); }); + it('returns embed preview mode when no browser-streamable format exists', async () => { + mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce( + new NoBrowserStreamableFormatError( + 'yt-dlp returned formats but none are browser-streamable progressive MP4' + ) + ); + + const response = await POST( + createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toEqual({ + ...resolvedMetadata, + previewMode: 'embed', + }); + expect(body.data.previewStreamUrl).toBeUndefined(); + expect(body.data.previewExpiresAt).toBeUndefined(); + }); + + it('returns 502 when preview media resolution fails for other reasons', async () => { + mockResolvePreviewDirectMediaUrl.mockRejectedValueOnce( + new Error('yt-dlp metadata lookup failed') + ); + + const response = await POST( + createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.message).toBe('yt-dlp metadata lookup failed'); + }); + it('returns 400 when mapYouTubeImportResolvedSource rejects the video', async () => { mockMapYouTubeImportResolvedSource.mockReturnValueOnce({ ok: false, diff --git a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx index b6e8b5f9..2670e545 100644 --- a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx +++ b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx @@ -38,14 +38,14 @@ function renderSlider( ) { const onChange = props.onChange ?? vi.fn(); const initialValue = props.value ?? { startSeconds: 10, endSeconds: 100 }; + const { onChange: _onChange, value: _value, ...sliderProps } = props; const view = render( ); return { onChange, ...view }; @@ -201,6 +201,26 @@ describe('TrimRangeSlider', () => { expect(onChange).toHaveBeenCalled(); }); + it('hides frame nudge buttons but keeps jump-step controls when showFrameNudge is false', () => { + renderSlider({ showFrameNudge: false }); + + expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument(); + expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument(); + expect(screen.getByTestId('trim-start-jump-later')).toBeInTheDocument(); + expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument(); + }); + + it('does not request keyframes when showFrameNudge is false and snapping is disabled', () => { + renderSlider({ showFrameNudge: false, enableKeyframeSnap: false }); + + fireEvent.click(screen.getByTestId('trim-start-jump-later')); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + it('snaps the moved handle to the closest returned keyframe after a frame nudge', async () => { const onChange = vi.fn(); renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } }); diff --git a/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx b/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx new file mode 100644 index 00000000..99f21d23 --- /dev/null +++ b/__tests__/components/youtube-import/YouTubeEmbedPreviewPlayer.test.tsx @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { YouTubeEmbedPreviewPlayer } from '@/components/youtube-import/YouTubeEmbedPreviewPlayer'; +import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer'; + +const VIDEO_ID = 'dQw4w9WgXcQ'; + +describe('YouTubeEmbedPreviewPlayer', () => { + const mockSeekTo = vi.fn(); + const mockGetCurrentTime = vi.fn(); + const mockDestroy = vi.fn(); + + class MockYouTubePlayer { + seekTo = mockSeekTo; + getCurrentTime = mockGetCurrentTime; + destroy = mockDestroy; + + constructor( + _element: HTMLElement, + options?: { + videoId?: string; + events?: { + onReady?: (event: { target: MockYouTubePlayer }) => void; + }; + } + ) { + options?.events?.onReady?.({ target: this }); + } + } + + beforeEach(() => { + vi.restoreAllMocks(); + mockSeekTo.mockReset(); + mockGetCurrentTime.mockReset(); + mockDestroy.mockReset(); + mockGetCurrentTime.mockReturnValue(42); + + window.YT = { + Player: MockYouTubePlayer as unknown as NonNullable['Player'], + }; + }); + + it('renders an embed preview container and creates a YT.Player for the video id', async () => { + const playerSpy = vi.spyOn(window.YT!, 'Player'); + const { container } = render(); + + expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument(); + + await waitFor(() => { + expect(playerSpy).toHaveBeenCalledWith( + container.querySelector('.aspect-video div'), + expect.objectContaining({ + videoId: VIDEO_ID, + }) + ); + }); + }); + + it('exposes previewAt and getCurrentTime through playerRef', async () => { + const playerRef = { current: null as YouTubePlayerHandle | null }; + + render(); + + await waitFor(() => { + expect(playerRef.current).not.toBeNull(); + }); + + playerRef.current?.previewAt(120); + + await waitFor(() => { + expect(mockSeekTo).toHaveBeenCalledWith(120, true); + }); + + expect(playerRef.current?.getCurrentTime()).toBe(42); + }); + + it('destroys the player on unmount', async () => { + const playerSpy = vi.spyOn(window.YT!, 'Player'); + const { unmount } = render(); + + await waitFor(() => { + expect(playerSpy).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockDestroy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/youtube-import/YouTubeImportModal.test.tsx b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx index c58d80f4..4ff9ea6d 100644 --- a/__tests__/components/youtube-import/YouTubeImportModal.test.tsx +++ b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx @@ -15,22 +15,16 @@ vi.mock('@/components/youtube-import/YouTubePreviewPlayer', () => ({ YouTubePreviewPlayer: () =>
, })); -vi.mock('@/components/youtube-import/TrimRangeSlider', () => ({ - TrimRangeSlider: ({ - onChange, - }: { - onChange: (value: { startSeconds: number; endSeconds: number }) => void; - }) => ( - - ), +vi.mock('@/components/youtube-import/YouTubeEmbedPreviewPlayer', () => ({ + YouTubeEmbedPreviewPlayer: () =>
, })); +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + const DRAFT_ID = 'draft-1'; const VIDEO_ID = 'dQw4w9WgXcQ'; @@ -39,10 +33,19 @@ const resolvedSource = { title: 'Sunday Service', durationSeconds: 3600, thumbnailUrl: 'https://img.youtube.com/high.jpg', + previewMode: 'direct' as const, previewStreamUrl: `/api/youtube-import/preview/stream?youtubeVideoId=${VIDEO_ID}`, previewExpiresAt: Date.now() + 3_600_000, }; +const embedResolvedSource = { + youtubeVideoId: VIDEO_ID, + title: 'Sunday Service', + durationSeconds: 3600, + thumbnailUrl: 'https://img.youtube.com/high.jpg', + previewMode: 'embed' as const, +}; + const livestreamRow: Livestream = { id: 'livestream-1', userId: 'user-1', @@ -204,6 +207,7 @@ function renderModal(props: Partial> = beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); afterEach(() => { @@ -246,6 +250,41 @@ describe('YouTubeImportModal', () => { ); }); + it('uses embed preview when resolve returns previewMode embed', async () => { + installFetchMock({ + resolve: () => new Response(JSON.stringify({ data: embedResolvedSource }), { status: 200 }), + }); + const user = userEvent.setup(); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + await user.type( + screen.getByLabelText(/youtube link/i), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ); + await user.click(screen.getByRole('button', { name: /use this link/i })); + + await waitFor(() => { + expect(screen.getByTestId('youtube-embed-preview-player')).toBeInTheDocument(); + expect(screen.queryByTestId('youtube-preview-player')).not.toBeInTheDocument(); + }); + + const smartCutCheckbox = screen.getByRole('checkbox', { name: /smart cut/i }); + expect(smartCutCheckbox).toBeChecked(); + expect(smartCutCheckbox).toBeDisabled(); + + expect(screen.queryByTestId('trim-start-frame-earlier')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-start-frame-later')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-end-frame-earlier')).not.toBeInTheDocument(); + expect(screen.queryByTestId('trim-end-frame-later')).not.toBeInTheDocument(); + expect(screen.getByTestId('trim-start-jump-earlier')).toBeInTheDocument(); + expect(screen.getByTestId('trim-jump-step-5')).toBeInTheDocument(); + }); + it('hides the preview player when Show video preview is unchecked', async () => { installFetchMock({}); const user = userEvent.setup(); diff --git a/__tests__/lib/youtube-import/probe-keyframes.test.ts b/__tests__/lib/youtube-import/probe-keyframes.test.ts index d2bd7afe..7f2f55d3 100644 --- a/__tests__/lib/youtube-import/probe-keyframes.test.ts +++ b/__tests__/lib/youtube-import/probe-keyframes.test.ts @@ -11,6 +11,7 @@ import { buildFfprobeReadInterval, getDirectMediaUrl, isBrowserStreamableMp4Format, + NoBrowserStreamableFormatError, parseFfprobeKeyframeCsv, parseFfprobeKeyframePacketCsv, pickYtDlpProbeFormat, @@ -493,4 +494,49 @@ describe('getDirectMediaUrl', () => { await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow(/timed out after 50ms/); }); + + it('throws NoBrowserStreamableFormatError when only DASH/HLS formats exist', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stdout: JSON.stringify({ + duration: 3600, + formats: [ + { + url: 'https://example.com/manifest.mpd', + height: 720, + vcodec: 'avc1', + acodec: 'none', + ext: 'mp4', + protocol: 'http_dash_segments', + format_id: '137', + }, + { + url: 'https://example.com/playlist.m3u8', + height: 720, + vcodec: 'avc1', + acodec: 'mp4a', + ext: 'mp4', + protocol: 'm3u8_native', + format_id: '96', + }, + ], + }), + }) + ); + + await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toBeInstanceOf( + NoBrowserStreamableFormatError + ); + expect(warnSpy).toHaveBeenCalledWith( + '[getDirectMediaUrl] No browser-streamable MP4 format; yt-dlp format list:', + [ + { protocol: 'http_dash_segments', ext: 'mp4', format_id: '137' }, + { protocol: 'm3u8_native', ext: 'mp4', format_id: '96' }, + ] + ); + + warnSpy.mockRestore(); + }); }); diff --git a/app/api/youtube-import/resolve/route.ts b/app/api/youtube-import/resolve/route.ts index 7ab5c4b5..c7e44ecb 100644 --- a/app/api/youtube-import/resolve/route.ts +++ b/app/api/youtube-import/resolve/route.ts @@ -16,6 +16,7 @@ import { buildYoutubeImportPreviewStreamPath, resolvePreviewDirectMediaUrl, } from '@/lib/youtube-import/preview-media-url'; +import { NoBrowserStreamableFormatError } from '@/lib/youtube-import/probe-keyframes'; import type { ApiError, ApiResponse } from '@/types'; interface ResolveYouTubeImportRequestBody { @@ -172,6 +173,16 @@ export async function POST(req: NextRequest): Promise { const previewMedia = await resolvePreviewDirectMediaUrl(userId, mapped.data.youtubeVideoId); previewExpiresAt = previewMedia.expiresAt; } catch (err) { + if (err instanceof NoBrowserStreamableFormatError) { + const res: ApiResponse = { + data: { + ...mapped.data, + previewMode: 'embed', + }, + }; + return NextResponse.json(res, { status: 200 }); + } + const message = err instanceof Error && err.message.trim() !== '' ? err.message.trim() @@ -182,6 +193,7 @@ export async function POST(req: NextRequest): Promise { const res: ApiResponse = { data: { ...mapped.data, + previewMode: 'direct', previewStreamUrl: buildYoutubeImportPreviewStreamPath(mapped.data.youtubeVideoId), previewExpiresAt, }, diff --git a/components/youtube-import/TrimRangeSlider.tsx b/components/youtube-import/TrimRangeSlider.tsx index 37d3c80e..c59f19ed 100644 --- a/components/youtube-import/TrimRangeSlider.tsx +++ b/components/youtube-import/TrimRangeSlider.tsx @@ -44,6 +44,8 @@ export interface TrimRangeSliderProps { playerHandle?: YouTubePlayerHandle; /** When false, trim handles stay without keyframe probing after precise edits. */ enableKeyframeSnap?: boolean; + /** When false, hides one-frame nudge buttons (embed preview mode). */ + showFrameNudge?: boolean; } /** @@ -302,6 +304,8 @@ interface TrimHandleControlsProps { disabled: boolean; /** Whether a keyframe snap is in progress for this handle. */ isSnapping: boolean; + /** Whether frame-by-frame nudge buttons are shown. */ + showFrameNudge: boolean; /** Selected coarse jump distance in seconds. */ jumpStepSeconds: TrimJumpStepSeconds; /** Whether the jump-earlier button is enabled. */ @@ -337,6 +341,7 @@ function TrimHandleControls({ seconds, disabled, isSnapping, + showFrameNudge, jumpStepSeconds, canJumpEarlier, canFrameEarlier, @@ -362,18 +367,20 @@ function TrimHandleControls({