From a497ec80f42378e5ef320168190e75a5c2f8903a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 11:26:28 +0000 Subject: [PATCH] feat(youtube): auto-transition broadcast to live (no more "Preparing stream") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the egress pushes a clean continuous stream to YouTube, YouTube still leaves the broadcast in ready/testing ("Preparing stream") until someone clicks Go Live (unless Auto-start is on). This adds the YouTube Live API integration to force it live automatically. - OAuth: /api/youtube/auth + /api/youtube/callback (Google consent, offline access). Refresh token stored in new youtube_credentials table (RLS-locked, service-role only — token never reaches the client). - lib/youtube.ts: token exchange/refresh + liveBroadcasts list/transition. - lib/youtubeAutoLive.ts: on egress start to a youtube.com URL, poll the user's broadcasts (~90s) and transition the first ready/testing one to live. Fire-and-forget; never throws or delays egress start; no-op unless the user connected YouTube. - /api/youtube/status + /disconnect, and a "Connect YouTube" card in Settings. Requires Railway env GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET and the migration applied. Dormant until configured. 8 new tests; lint + tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../src/app/api/stream/egress/start/route.ts | 8 ++ apps/web/src/app/api/youtube/auth/route.ts | 30 ++++ .../web/src/app/api/youtube/callback/route.ts | 52 +++++++ .../src/app/api/youtube/disconnect/route.ts | 13 ++ apps/web/src/app/api/youtube/status/route.ts | 23 +++ .../web/src/app/settings/settings-content.tsx | 4 + apps/web/src/app/settings/youtube-connect.tsx | 96 +++++++++++++ apps/web/src/lib/youtube.test.ts | 78 +++++++++++ apps/web/src/lib/youtube.ts | 132 ++++++++++++++++++ apps/web/src/lib/youtubeAutoLive.test.ts | 55 ++++++++ apps/web/src/lib/youtubeAutoLive.ts | 53 +++++++ .../20260623000001_youtube_credentials.sql | 20 +++ 12 files changed, 564 insertions(+) create mode 100644 apps/web/src/app/api/youtube/auth/route.ts create mode 100644 apps/web/src/app/api/youtube/callback/route.ts create mode 100644 apps/web/src/app/api/youtube/disconnect/route.ts create mode 100644 apps/web/src/app/api/youtube/status/route.ts create mode 100644 apps/web/src/app/settings/youtube-connect.tsx create mode 100644 apps/web/src/lib/youtube.test.ts create mode 100644 apps/web/src/lib/youtube.ts create mode 100644 apps/web/src/lib/youtubeAutoLive.test.ts create mode 100644 apps/web/src/lib/youtubeAutoLive.ts create mode 100644 supabase/migrations/20260623000001_youtube_credentials.sql diff --git a/apps/web/src/app/api/stream/egress/start/route.ts b/apps/web/src/app/api/stream/egress/start/route.ts index d32d8058..f38bf2fa 100644 --- a/apps/web/src/app/api/stream/egress/start/route.ts +++ b/apps/web/src/app/api/stream/egress/start/route.ts @@ -3,6 +3,7 @@ import { createClient as createSupabaseAdminClient } from '@supabase/supabase-js import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { getEgressClient } from '@/lib/livekit-egress'; +import { autoTransitionYouTube } from '@/lib/youtubeAutoLive'; import type { Database, Session } from '@pairux/shared-types'; /** @@ -122,6 +123,13 @@ export async function POST(request: Request) { { layout: 'speaker', encodingOptions } ); + // If streaming to YouTube and the host has connected their YouTube account, + // auto-transition the broadcast to live so it never sits on "Preparing". + // Fire-and-forget — it polls for ~90s and must not delay/fail egress start. + if (rtmpUrls.some((u) => /(^|\.)youtube\.com\//i.test(u))) { + void autoTransitionYouTube(user.id); + } + // egressIds[] kept for clients that track the full set; egressId for older ones. return successResponse({ egressIds: [info.egressId], egressId: info.egressId }); } catch (error) { diff --git a/apps/web/src/app/api/youtube/auth/route.ts b/apps/web/src/app/api/youtube/auth/route.ts new file mode 100644 index 00000000..f04f6122 --- /dev/null +++ b/apps/web/src/app/api/youtube/auth/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { buildAuthUrl, youtubeOAuthConfigured } from '@/lib/youtube'; + +/** Start the YouTube OAuth flow: set a CSRF state cookie, redirect to Google. */ +export async function GET(request: Request) { + const appBase = process.env.NEXT_PUBLIC_APP_URL ?? new URL(request.url).origin; + + if (!youtubeOAuthConfigured()) { + return NextResponse.redirect(new URL('/settings?youtube=unconfigured', appBase)); + } + + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + if (!user) { + return NextResponse.redirect(new URL('/login', appBase)); + } + + const state = randomBytes(16).toString('hex'); + const res = NextResponse.redirect(buildAuthUrl(state)); + res.cookies.set('yt_oauth_state', state, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 600, + }); + return res; +} diff --git a/apps/web/src/app/api/youtube/callback/route.ts b/apps/web/src/app/api/youtube/callback/route.ts new file mode 100644 index 00000000..70bf4f50 --- /dev/null +++ b/apps/web/src/app/api/youtube/callback/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { exchangeCodeForTokens } from '@/lib/youtube'; + +/** Google redirects here with ?code. Verify state, store the refresh token. */ +export async function GET(request: Request) { + const url = new URL(request.url); + const appBase = process.env.NEXT_PUBLIC_APP_URL ?? url.origin; + const back = (status: string) => + NextResponse.redirect(new URL(`/settings?youtube=${status}`, appBase)); + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + if (url.searchParams.get('error') || !code) return back('error'); + + const cookieStore = await cookies(); + const expectedState = cookieStore.get('yt_oauth_state')?.value; + if (!state || !expectedState || state !== expectedState) return back('error'); + + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + if (!user) return NextResponse.redirect(new URL('/login', appBase)); + + try { + const tokens = await exchangeCodeForTokens(code); + if (!tokens.refresh_token) { + // Google only returns a refresh token on first consent. Re-consent forces + // it (prompt=consent), but guard anyway. + return back('noref'); + } + + // youtube_credentials isn't in the generated Database types yet (migration + // ships in this PR), so the typed client infers `never` for the payload. + await serviceClient() + .from('youtube_credentials') + .upsert({ + user_id: user.id, + refresh_token: tokens.refresh_token, + scope: tokens.scope ?? null, + updated_at: new Date().toISOString(), + } as never); + + const res = back('connected'); + res.cookies.delete('yt_oauth_state'); + return res; + } catch (err) { + console.error('[youtube/callback] failed:', err); + return back('error'); + } +} diff --git a/apps/web/src/app/api/youtube/disconnect/route.ts b/apps/web/src/app/api/youtube/disconnect/route.ts new file mode 100644 index 00000000..458fcbf9 --- /dev/null +++ b/apps/web/src/app/api/youtube/disconnect/route.ts @@ -0,0 +1,13 @@ +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse } from '@/lib/api'; + +/** Remove the current user's stored YouTube credentials. */ +export async function POST() { + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + if (!user) return errorResponse('Authentication required', 401); + + await serviceClient().from('youtube_credentials').delete().eq('user_id', user.id); + return successResponse({ disconnected: true }); +} diff --git a/apps/web/src/app/api/youtube/status/route.ts b/apps/web/src/app/api/youtube/status/route.ts new file mode 100644 index 00000000..72273105 --- /dev/null +++ b/apps/web/src/app/api/youtube/status/route.ts @@ -0,0 +1,23 @@ +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse } from '@/lib/api'; +import { youtubeOAuthConfigured } from '@/lib/youtube'; + +/** Whether the current user has connected YouTube (token never exposed). */ +export async function GET() { + const supabase = await createClient(); + const { user } = await getAuthenticatedUser(supabase); + if (!user) return errorResponse('Authentication required', 401); + + const { data } = await serviceClient() + .from('youtube_credentials') + .select('user_id, channel_title') + .eq('user_id', user.id) + .maybeSingle(); + + return successResponse({ + configured: youtubeOAuthConfigured(), + connected: Boolean(data), + channelTitle: (data as { channel_title?: string } | null)?.channel_title ?? null, + }); +} diff --git a/apps/web/src/app/settings/settings-content.tsx b/apps/web/src/app/settings/settings-content.tsx index 3423f03e..78e5b237 100644 --- a/apps/web/src/app/settings/settings-content.tsx +++ b/apps/web/src/app/settings/settings-content.tsx @@ -6,6 +6,7 @@ import { User, Video, Users, Palette, Info, ArrowLeft, Check, Bell } from 'lucid import { HeaderClient } from '@/components/header-client'; import { Footer } from '@/components/footer'; import { NotificationPreferences } from '@/components/notifications/NotificationPreferences'; +import { YouTubeConnect } from './youtube-connect'; import type { UserData } from '@/components/header'; import type { RecordingQuality } from '@/hooks/useRecording'; import type { CaptureQuality } from '@/hooks/useScreenCapture'; @@ -104,6 +105,9 @@ export function SettingsContent({ user }: { user: UserData | null }) {
+ {/* YouTube auto go-live */} + + {/* Account Section */}
diff --git a/apps/web/src/app/settings/youtube-connect.tsx b/apps/web/src/app/settings/youtube-connect.tsx new file mode 100644 index 00000000..33b19747 --- /dev/null +++ b/apps/web/src/app/settings/youtube-connect.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Radio, Check, Loader2 } from 'lucide-react'; + +interface YtStatus { + configured: boolean; + connected: boolean; + channelTitle: string | null; +} + +/** + * Connect a YouTube account so PairUX auto-transitions the broadcast to live + * when you start streaming (instead of it sitting on "Preparing stream"). + */ +export function YouTubeConnect() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/youtube/status'); + if (res.ok) { + const json = (await res.json()) as { data: YtStatus }; + setStatus(json.data); + } + } catch { + // leave status null — section shows a spinner / nothing actionable + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const disconnect = useCallback(async () => { + setBusy(true); + try { + await fetch('/api/youtube/disconnect', { method: 'POST' }); + await load(); + } finally { + setBusy(false); + } + }, [load]); + + return ( +
+
+
+
+ +
+
+

YouTube auto go-live

+

+ Take your YouTube broadcast live automatically when you start streaming — no more + “Preparing stream”. +

+
+
+
+
+ {status === null ? ( + + ) : !status.configured ? ( +

+ Not available yet — the server is missing Google OAuth configuration. +

+ ) : status.connected ? ( +
+ + + Connected{status.channelTitle ? ` — ${status.channelTitle}` : ''} + + +
+ ) : ( + + + Connect YouTube + + )} +
+
+ ); +} diff --git a/apps/web/src/lib/youtube.test.ts b/apps/web/src/lib/youtube.test.ts new file mode 100644 index 00000000..a8ddd7c7 --- /dev/null +++ b/apps/web/src/lib/youtube.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + buildAuthUrl, + findTransitionableBroadcasts, + transitionToLive, + exchangeCodeForTokens, + youtubeOAuthConfigured, +} from './youtube'; + +describe('youtube lib', () => { + beforeEach(() => { + process.env.GOOGLE_CLIENT_ID = 'cid'; + process.env.GOOGLE_CLIENT_SECRET = 'secret'; + process.env.NEXT_PUBLIC_APP_URL = 'https://pairux.com'; + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('youtubeOAuthConfigured reflects env presence', () => { + expect(youtubeOAuthConfigured()).toBe(true); + delete process.env.GOOGLE_CLIENT_ID; + expect(youtubeOAuthConfigured()).toBe(false); + }); + + it('buildAuthUrl requests offline access + consent with scope, state and redirect', () => { + const url = new URL(buildAuthUrl('xyz')); + expect(url.searchParams.get('client_id')).toBe('cid'); + expect(url.searchParams.get('access_type')).toBe('offline'); + expect(url.searchParams.get('prompt')).toBe('consent'); + expect(url.searchParams.get('state')).toBe('xyz'); + expect(url.searchParams.get('scope')).toContain('youtube'); + expect(url.searchParams.get('redirect_uri')).toBe('https://pairux.com/api/youtube/callback'); + }); + + it('findTransitionableBroadcasts returns only ready/testing broadcasts', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { id: 'a', status: { lifeCycleStatus: 'ready' }, snippet: { title: 'A' } }, + { id: 'b', status: { lifeCycleStatus: 'live' }, snippet: { title: 'B' } }, + { id: 'c', status: { lifeCycleStatus: 'testing' }, snippet: { title: 'C' } }, + { id: 'd', status: { lifeCycleStatus: 'complete' }, snippet: { title: 'D' } }, + ], + }), + }) as unknown as typeof fetch; + + const res = await findTransitionableBroadcasts('tok'); + expect(res.map((b) => b.id)).toEqual(['a', 'c']); + }); + + it('transitionToLive POSTs to the transition endpoint with broadcastStatus=live', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve('') }); + global.fetch = fetchMock as unknown as typeof fetch; + + await transitionToLive('tok', 'bid-1'); + + const [calledUrl, opts] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(calledUrl).toContain('/liveBroadcasts/transition'); + expect(calledUrl).toContain('broadcastStatus=live'); + expect(calledUrl).toContain('id=bid-1'); + expect(opts.method).toBe('POST'); + expect((opts.headers as Record).Authorization).toBe('Bearer tok'); + }); + + it('exchangeCodeForTokens returns the parsed token response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }), + }) as unknown as typeof fetch; + + const t = await exchangeCodeForTokens('code'); + expect(t.refresh_token).toBe('rt'); + expect(t.access_token).toBe('at'); + }); +}); diff --git a/apps/web/src/lib/youtube.ts b/apps/web/src/lib/youtube.ts new file mode 100644 index 00000000..869fdc4d --- /dev/null +++ b/apps/web/src/lib/youtube.ts @@ -0,0 +1,132 @@ +/** + * YouTube Live API helpers — OAuth + auto-transition a stuck "Preparing" + * broadcast to live. + * + * The egress pushes a clean continuous RTMP stream to YouTube, but YouTube + * leaves the broadcast in `ready`/`testing` ("Preparing stream") until someone + * clicks "Go Live" (unless Auto-start is on). With the user's YouTube OAuth we + * can detect that state and call liveBroadcasts.transition → live automatically. + * + * Requires env: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET. Redirect URI is derived + * from NEXT_PUBLIC_APP_URL (or APP_URL), defaulting to https://pairux.com. + */ + +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const YT_API = 'https://www.googleapis.com/youtube/v3'; + +// Manage the user's live broadcasts (list + transition). +export const YOUTUBE_SCOPE = 'https://www.googleapis.com/auth/youtube'; + +export function youtubeOAuthConfigured(): boolean { + return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); +} + +export function getRedirectUri(): string { + const base = process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://pairux.com'; + return `${base.replace(/\/$/, '')}/api/youtube/callback`; +} + +/** Build the Google consent URL. `state` ties the callback back to the user. */ +export function buildAuthUrl(state: string): string { + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + redirect_uri: getRedirectUri(), + response_type: 'code', + scope: YOUTUBE_SCOPE, + access_type: 'offline', // get a refresh token + prompt: 'consent', // force refresh token on re-consent + include_granted_scopes: 'true', + state, + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; +} + +/** Exchange an authorization code for tokens (includes a refresh_token). */ +export async function exchangeCodeForTokens(code: string): Promise { + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '', + redirect_uri: getRedirectUri(), + grant_type: 'authorization_code', + }), + }); + if (!res.ok) { + throw new Error(`Google token exchange failed: ${String(res.status)} ${await res.text()}`); + } + return (await res.json()) as TokenResponse; +} + +/** Trade a stored refresh token for a fresh access token. */ +export async function refreshAccessToken(refreshToken: string): Promise { + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + refresh_token: refreshToken, + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '', + grant_type: 'refresh_token', + }), + }); + if (!res.ok) { + throw new Error(`Google token refresh failed: ${String(res.status)} ${await res.text()}`); + } + const json = (await res.json()) as TokenResponse; + return json.access_token; +} + +export interface LiveBroadcast { + id: string; + lifeCycleStatus: string; // created | ready | testing | live | complete | revoked + title: string; +} + +/** + * List the user's broadcasts that are waiting to go live (ready/testing). + * These are the ones stuck on "Preparing". + */ +export async function findTransitionableBroadcasts(accessToken: string): Promise { + const url = + `${YT_API}/liveBroadcasts?part=id,snippet,status` + + `&broadcastStatus=upcoming&maxResults=10&broadcastType=all`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) { + throw new Error(`liveBroadcasts.list failed: ${String(res.status)} ${await res.text()}`); + } + const json = (await res.json()) as { + items?: { id: string; status?: { lifeCycleStatus?: string }; snippet?: { title?: string } }[]; + }; + return (json.items ?? []) + .map((it) => ({ + id: it.id, + lifeCycleStatus: it.status?.lifeCycleStatus ?? '', + title: it.snippet?.title ?? '', + })) + .filter((b) => b.lifeCycleStatus === 'ready' || b.lifeCycleStatus === 'testing'); +} + +/** Transition a broadcast to live. Idempotent-ish: YouTube 4xxs if not ready. */ +export async function transitionToLive(accessToken: string, broadcastId: string): Promise { + const url = + `${YT_API}/liveBroadcasts/transition` + + `?part=status&broadcastStatus=live&id=${encodeURIComponent(broadcastId)}`; + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw new Error(`liveBroadcasts.transition failed: ${String(res.status)} ${await res.text()}`); + } +} diff --git a/apps/web/src/lib/youtubeAutoLive.test.ts b/apps/web/src/lib/youtubeAutoLive.test.ts new file mode 100644 index 00000000..e2044af2 --- /dev/null +++ b/apps/web/src/lib/youtubeAutoLive.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMaybeSingle = vi.fn(); +vi.mock('@/lib/supabase/service', () => ({ + serviceClient: () => ({ + from: () => ({ select: () => ({ eq: () => ({ maybeSingle: mockMaybeSingle }) }) }), + }), +})); + +const mockRefresh = vi.fn(); +const mockFind = vi.fn(); +const mockTransition = vi.fn(); +vi.mock('@/lib/youtube', () => ({ + youtubeOAuthConfigured: () => true, + refreshAccessToken: (...a: unknown[]) => mockRefresh(...a), + findTransitionableBroadcasts: (...a: unknown[]) => mockFind(...a), + transitionToLive: (...a: unknown[]) => mockTransition(...a), +})); + +import { autoTransitionYouTube } from './youtubeAutoLive'; + +describe('autoTransitionYouTube', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('transitions the first ready broadcast to live', async () => { + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'rt' } }); + mockRefresh.mockResolvedValue('access-tok'); + mockFind.mockResolvedValue([{ id: 'b1', lifeCycleStatus: 'ready', title: 'X' }]); + mockTransition.mockResolvedValue(undefined); + + await autoTransitionYouTube('user-1'); + + expect(mockRefresh).toHaveBeenCalledWith('rt'); + expect(mockTransition).toHaveBeenCalledWith('access-tok', 'b1'); + }); + + it('does nothing when the user has not connected YouTube', async () => { + mockMaybeSingle.mockResolvedValue({ data: null }); + + await autoTransitionYouTube('user-1'); + + expect(mockRefresh).not.toHaveBeenCalled(); + expect(mockTransition).not.toHaveBeenCalled(); + }); + + it('never throws when the YouTube API fails', async () => { + mockMaybeSingle.mockResolvedValue({ data: { refresh_token: 'rt' } }); + mockRefresh.mockRejectedValue(new Error('google down')); + + await expect(autoTransitionYouTube('user-1')).resolves.toBeUndefined(); + expect(mockTransition).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/youtubeAutoLive.ts b/apps/web/src/lib/youtubeAutoLive.ts new file mode 100644 index 00000000..832476c3 --- /dev/null +++ b/apps/web/src/lib/youtubeAutoLive.ts @@ -0,0 +1,53 @@ +import { serviceClient } from '@/lib/supabase/service'; +import { + refreshAccessToken, + findTransitionableBroadcasts, + transitionToLive, + youtubeOAuthConfigured, +} from '@/lib/youtube'; + +/** How long/often to poll for a broadcast that's ready to go live. */ +const POLL_INTERVAL_MS = 6_000; +const MAX_ATTEMPTS = 15; // ~90s — enough for YouTube to ingest + reach "ready" + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Fetch a user's stored YouTube refresh token, or null if not connected. */ +async function getRefreshToken(userId: string): Promise { + const { data } = await serviceClient() + .from('youtube_credentials') + .select('refresh_token') + .eq('user_id', userId) + .maybeSingle(); + const token = (data as { refresh_token?: string } | null)?.refresh_token; + return token ?? null; +} + +/** + * Best-effort: after egress starts pushing to YouTube, poll the user's + * broadcasts and transition the first ready/testing one to live — so it never + * sits on "Preparing stream". Fire-and-forget; never throws. + */ +export async function autoTransitionYouTube(userId: string): Promise { + try { + if (!youtubeOAuthConfigured()) return; + + const refreshToken = await getRefreshToken(userId); + if (!refreshToken) return; // user hasn't connected YouTube + + const accessToken = await refreshAccessToken(refreshToken); + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const [first] = await findTransitionableBroadcasts(accessToken); + if (first) { + await transitionToLive(accessToken, first.id); + console.log(`[youtube] transitioned broadcast ${first.id} to live for user ${userId}`); + return; + } + await sleep(POLL_INTERVAL_MS); + } + console.warn(`[youtube] no ready broadcast to transition for user ${userId} after polling`); + } catch (err) { + console.warn('[youtube] auto-transition failed:', err); + } +} diff --git a/supabase/migrations/20260623000001_youtube_credentials.sql b/supabase/migrations/20260623000001_youtube_credentials.sql new file mode 100644 index 00000000..9f96759b --- /dev/null +++ b/supabase/migrations/20260623000001_youtube_credentials.sql @@ -0,0 +1,20 @@ +-- YouTube OAuth credentials (per user) +-- Stores the user's YouTube refresh token so the server can auto-transition a +-- "Preparing" broadcast to live via the YouTube Live API. +-- +-- The refresh_token is sensitive and SERVER-ONLY. RLS is enabled with NO +-- policies, so PostgREST/anon/authenticated clients cannot read it — only the +-- service-role key (which bypasses RLS) can, from server routes. Connected +-- status is surfaced to the client via a server route, never the token itself. + +CREATE TABLE IF NOT EXISTS public.youtube_credentials ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + refresh_token TEXT NOT NULL, + channel_title TEXT, + scope TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Enable RLS with no policies => deny all client access; service role bypasses. +ALTER TABLE public.youtube_credentials ENABLE ROW LEVEL SECURITY;