Skip to content
Merged
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
8 changes: 8 additions & 0 deletions apps/web/src/app/api/stream/egress/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/app/api/youtube/auth/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions apps/web/src/app/api/youtube/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
13 changes: 13 additions & 0 deletions apps/web/src/app/api/youtube/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
23 changes: 23 additions & 0 deletions apps/web/src/app/api/youtube/status/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
4 changes: 4 additions & 0 deletions apps/web/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,9 @@ export function SettingsContent({ user }: { user: UserData | null }) {
</div>

<div className="space-y-6">
{/* YouTube auto go-live */}
<YouTubeConnect />

{/* Account Section */}
<div className="rounded-xl border border-gray-200 bg-white">
<div className="border-b border-gray-100 p-6">
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/app/settings/youtube-connect.tsx
Original file line number Diff line number Diff line change
@@ -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<YtStatus | null>(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 (
<div className="rounded-xl border border-gray-200 bg-white">
<div className="border-b border-gray-100 p-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<Radio className="h-5 w-5 text-red-600" />
</div>
<div>
<h2 className="font-semibold text-gray-900">YouTube auto go-live</h2>
<p className="text-sm text-gray-500">
Take your YouTube broadcast live automatically when you start streaming — no more
&ldquo;Preparing stream&rdquo;.
</p>
</div>
</div>
</div>
<div className="p-6">
{status === null ? (
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
) : !status.configured ? (
<p className="text-sm text-gray-500">
Not available yet — the server is missing Google OAuth configuration.
</p>
) : status.connected ? (
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2 text-sm text-green-600">
<Check className="h-4 w-4" />
Connected{status.channelTitle ? ` — ${status.channelTitle}` : ''}
</span>
<button
type="button"
onClick={() => void disconnect()}
disabled={busy}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
Disconnect
</button>
</div>
) : (
<a
href="/api/youtube/auth"
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
<Radio className="h-4 w-4" />
Connect YouTube
</a>
)}
</div>
</div>
);
}
78 changes: 78 additions & 0 deletions apps/web/src/lib/youtube.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>).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');
});
});
Loading
Loading