Skip to content

Commit a2ffe61

Browse files
ralyodioclaude
andcommitted
feat(channels): channel-level RTMP restreaming (configure once, auto on go-live)
Per-channel external restream destinations (YouTube/Twitch/etc.) + a master 'External restreaming' toggle in the dashboard. Keys stored service-only under RLS (repo's youtube_credentials pattern) — the client never sends/receives a key; the server builds rtmp_url/key at go-live. When a channel with restream_enabled + enabled destinations goes public, the visibility route auto-starts a LiveKit egress fanning out to them; stopped on host-leave. - migration: channel_restream_destinations + channels.restream_enabled + sessions.restream_egress_id + owner RPCs; list_my_channels returns the flag. - lib/channel-restream.ts start/stop; wired into visibility + host_leave. - /api/channels/[id]/restream CRUD + RestreamManager dashboard panel. Desktop global toggle to follow as its own release (per-channel switch already makes this work end-to-end). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 12762ca commit a2ffe61

10 files changed

Lines changed: 651 additions & 2 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
2+
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
3+
4+
interface RouteParams {
5+
params: Promise<{ channelId: string; destId: string }>;
6+
}
7+
8+
// DELETE — remove a restream destination.
9+
export async function DELETE(_request: Request, { params }: RouteParams) {
10+
try {
11+
const { channelId, destId } = await params;
12+
const supabase = await createClient();
13+
const { user, error: authError } = await getAuthenticatedUser(supabase);
14+
if (authError || !user) return errorResponse('Authentication required', 401);
15+
16+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
17+
const { error } = await (supabase.rpc as any)('delete_channel_restream_destination', {
18+
p_channel_id: channelId,
19+
p_id: destId,
20+
});
21+
if (error) {
22+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
23+
return errorResponse(error.message, 400);
24+
}
25+
return successResponse({ ok: true });
26+
} catch (error) {
27+
return handleApiError(error);
28+
}
29+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */
2+
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
3+
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
4+
5+
interface RouteParams {
6+
params: Promise<{ channelId: string }>;
7+
}
8+
9+
interface UpsertBody {
10+
id?: string;
11+
platform?: string;
12+
label?: string;
13+
rtmpUrl?: string;
14+
streamKey?: string;
15+
enabled?: boolean;
16+
}
17+
18+
// GET — list a channel's restream destinations (never returns stream keys).
19+
export async function GET(_request: Request, { params }: RouteParams) {
20+
try {
21+
const { channelId } = await params;
22+
const supabase = await createClient();
23+
const { user, error: authError } = await getAuthenticatedUser(supabase);
24+
if (authError || !user) return errorResponse('Authentication required', 401);
25+
26+
const { data, error } = await (supabase.rpc as any)('list_channel_restream_destinations', {
27+
p_channel_id: channelId,
28+
});
29+
if (error) {
30+
return errorResponse(error.message, 400);
31+
}
32+
return successResponse({ destinations: data });
33+
} catch (error) {
34+
return handleApiError(error);
35+
}
36+
}
37+
38+
// POST — add (no id) or update a destination.
39+
export async function POST(request: Request, { params }: RouteParams) {
40+
try {
41+
const { channelId } = await params;
42+
const body = (await request.json().catch(() => ({}))) as UpsertBody;
43+
const supabase = await createClient();
44+
const { user, error: authError } = await getAuthenticatedUser(supabase);
45+
if (authError || !user) return errorResponse('Authentication required', 401);
46+
47+
const { data, error } = await (supabase.rpc as any)('upsert_channel_restream_destination', {
48+
p_channel_id: channelId,
49+
p_id: body.id ?? null,
50+
p_platform: body.platform ?? 'custom',
51+
p_label: body.label ?? null,
52+
p_rtmp_url: body.rtmpUrl ?? '',
53+
p_stream_key: body.streamKey ?? null,
54+
p_enabled: body.enabled ?? true,
55+
});
56+
if (error) {
57+
return errorResponse(error.message, 400);
58+
}
59+
return successResponse({ id: data });
60+
} catch (error) {
61+
return handleApiError(error);
62+
}
63+
}
64+
65+
// PATCH — toggle the channel's restream master switch. Body: { enabled }.
66+
export async function PATCH(request: Request, { params }: RouteParams) {
67+
try {
68+
const { channelId } = await params;
69+
const body = (await request.json().catch(() => ({}))) as { enabled?: boolean };
70+
const supabase = await createClient();
71+
const { user, error: authError } = await getAuthenticatedUser(supabase);
72+
if (authError || !user) return errorResponse('Authentication required', 401);
73+
74+
const { error } = await (supabase.rpc as any)('set_channel_restream_enabled', {
75+
p_channel_id: channelId,
76+
p_enabled: Boolean(body.enabled),
77+
});
78+
if (error) {
79+
return errorResponse(error.message, 400);
80+
}
81+
return successResponse({ enabled: Boolean(body.enabled) });
82+
} catch (error) {
83+
return handleApiError(error);
84+
}
85+
}

apps/web/src/app/api/sessions/[sessionId]/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,14 @@ export async function DELETE(_request: Request, { params }: RouteParams) {
5757
return errorResponse(error.message, 400);
5858
}
5959

60-
// Host left → stop any server-side recording for this session (the egress
61-
// also self-stops when the room empties, so this is best-effort).
60+
// Host left → stop any server-side recording + external restream for this
61+
// session (the egress also self-stops when the room empties; best-effort).
6262
void import('@/lib/livekit-recording').then(({ stopSessionRecording }) =>
6363
stopSessionRecording(sessionId)
6464
);
65+
void import('@/lib/channel-restream').then(({ stopChannelRestream }) =>
66+
stopChannelRestream(sessionId)
67+
);
6568

6669
return successResponse(data);
6770
} catch (error) {

apps/web/src/app/api/sessions/[sessionId]/visibility/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ export async function PATCH(request: Request, { params }: RouteParams) {
5252
if (live?.creator_id) {
5353
void import('@/lib/notify-live').then(({ notifyGoLive }) => notifyGoLive(live));
5454
}
55+
56+
// Auto-restream to external RTMP if this live's channel has it enabled.
57+
// Resolve the channel from the flip, else query (flip is null if the
58+
// went-live moment already fired via the heartbeat path).
59+
let channelId = live?.channel_id ?? null;
60+
if (!channelId) {
61+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
62+
const { data: s } = (await supabase
63+
.from('sessions')
64+
.select('channel_id')
65+
.eq('id', sessionId)
66+
.single()) as { data: { channel_id: string | null } | null };
67+
channelId = s?.channel_id ?? null;
68+
}
69+
if (channelId) {
70+
const cid = channelId;
71+
void import('@/lib/channel-restream').then(({ startChannelRestream }) =>
72+
startChannelRestream(sessionId, cid)
73+
);
74+
}
5575
}
5676

5777
return successResponse(data);

apps/web/src/app/dashboard/ChannelsManager.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Sparkles,
1414
} from 'lucide-react';
1515
import type { MyChannel } from '@pairux/shared-types';
16+
import { RestreamManager } from './RestreamManager';
1617

1718
// Where OBS / any RTMP client points. The stream key selects the channel.
1819
const RTMP_INGEST_URL = 'rtmp://rtmp.pairux.com/live';
@@ -430,6 +431,10 @@ export function ChannelsManager() {
430431
</button>
431432
</div>
432433
</div>
434+
435+
<div className="mt-4">
436+
<RestreamManager channelId={ch.id} initialEnabled={ch.restream_enabled} />
437+
</div>
433438
</div>
434439
</div>
435440
);

0 commit comments

Comments
 (0)