diff --git a/apps/web/.env.example b/apps/web/.env.example index ad48fcd..ae709a0 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -21,11 +21,13 @@ HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891 MEDIAMTX_API_HQ=http://localhost:9997 NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890 +NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ=http://localhost:8889 # commented because we don't have another ingest server as of right now # NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991 # MEDIAMTX_API_ASIA=http://localhost:9999 # NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990 -# idt you should change this -MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge \ No newline at end of file +# generate with `openssl rand -base64 20` +MEDIAMTX_PUBLISH_KEY= +MEDIAMTX_API_KEY= diff --git a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts index 7defa24..919aff2 100644 --- a/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts +++ b/apps/web/src/app/(ui)/(protected)/api/mediamtx/publish/route.ts @@ -28,7 +28,7 @@ export async function POST(request: NextRequest) { action = parsedAction; protocol = parsedProtocol; - if (parsedAction === 'publish' && parsedProtocol === 'srt') { + if (parsedAction === 'publish' && (parsedProtocol === 'srt' || parsedProtocol === 'webrtc')) { const channelKey = await redis.get(`streamKey:${path}`); if (channelKey) { @@ -69,7 +69,8 @@ export async function POST(request: NextRequest) { return finish('youre in yay', 200, 'authorized_publish'); } - } else if (parsedAction === 'read' && parsedProtocol === 'hls') { + } + if (parsedAction === 'read' && parsedProtocol === 'hls') { if (password === process.env.MEDIAMTX_PUBLISH_KEY) { return finish('authorized (hls read key for thumbs)', 200, 'authorized_thumbnail'); } @@ -79,6 +80,13 @@ export async function POST(request: NextRequest) { } return finish('authorized', 200, 'authorized_read'); } + if (parsedAction === 'api') { + if (password === process.env.MEDIAMTX_API_KEY) { + return finish('authorized api', 200, 'authorized_api'); + } + + return finish('unauthorized api', 401, 'unauthorized_api'); + } return finish('uhh', 401, 'unauthorized'); } diff --git a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts index 52497d9..6c23468 100644 --- a/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts +++ b/apps/web/src/app/(ui)/(protected)/api/rtmp/streamKey/route.ts @@ -1,46 +1,106 @@ +import { NextRequest } from 'next/server'; import { validateRequest } from '@/lib/auth/validate'; -import { prisma } from '@hctv/db'; -import { NextRequest } from "next/server"; import { regenerateStreamKey } from '@/lib/db/streamKey'; +import { prisma } from '@hctv/db'; export async function POST(request: NextRequest) { + const channelName = await readChannelNameFromBody(request); + + if (!channelName) { + return badRequestResponse(); + } + + const result = await getAuthorizedChannel(channelName); + if ('response' in result) { + return result.response; + } + + const streamKey = await regenerateStreamKey(result.channel.id, channelName); + return Response.json({ key: streamKey.key }); +} + +export async function GET(request: NextRequest) { + const channelName = request.nextUrl.searchParams.get('channel'); + + if (!isValidChannelName(channelName)) { + return badRequestResponse(); + } + + const result = await getAuthorizedChannel(channelName); + if ('response' in result) { + return result.response; + } + + const streamKey = await prisma.streamKey.findUnique({ + where: { channelId: result.channel.id }, + select: { key: true }, + }); + + if (!streamKey) { + return new Response('Stream key not found', { status: 404 }); + } + + return Response.json({ key: streamKey.key }); +} + +async function getAuthorizedChannel(channelName: string): Promise { const { user } = await validateRequest(); - const body = await request.json(); - const { channel } = body; if (!user) { - return new Response('Unauthorized', { status: 401 }); + return { response: unauthorizedResponse() }; } - if (!channel || typeof channel !== 'string') { - return new Response('Bad Request', { status: 400 }); - } - - const channelInfo = await prisma.channel.findUnique({ - where: { name: channel }, - include: { - owner: true, - managers: true - } + const channel = await prisma.channel.findUnique({ + where: { name: channelName }, + select: { + id: true, + ownerId: true, + managers: { + where: { id: user.id }, + select: { id: true }, + }, + }, }); - if (!channelInfo) { - return new Response('Channel not found', { status: 404 }); + if (!channel) { + return { response: new Response('Channel not found', { status: 404 }) }; } - const isBroadcaster = - channelInfo.ownerId === user.id || - channelInfo.managers.some(m => m.id === user.id); + const isBroadcaster = channel.ownerId === user.id || channel.managers.length > 0; if (!isBroadcaster) { - return new Response('Unauthorized', { status: 401 }); + return { response: unauthorizedResponse() }; } - const streamKey = await regenerateStreamKey(channelInfo.id, channel); + return { channel: { id: channel.id } }; +} - return new Response(JSON.stringify({ key: streamKey.key }), { - status: 200, - headers: { - 'Content-Type': 'application/json' +async function readChannelNameFromBody(request: NextRequest) { + try { + const body = await request.json(); + return isValidChannelName(body?.channel) ? body.channel : null; + } catch { + return null; + } +} + +function isValidChannelName(channelName: unknown): channelName is string { + return typeof channelName === 'string' && channelName.length > 0; +} + +function badRequestResponse() { + return new Response('Bad Request', { status: 400 }); +} + +function unauthorizedResponse() { + return new Response('Unauthorized', { status: 401 }); +} + +type AuthorizedChannelResult = + | { + channel: { + id: string; + }; } - }); -} \ No newline at end of file + | { + response: Response; + }; diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx index 484b1d5..16f9f57 100644 --- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx @@ -67,6 +67,7 @@ import { parseAsString, useQueryState } from 'nuqs'; import { Write } from '@/components/ui/channel-desc-fancy-area/write'; import { Preview } from '@/components/ui/channel-desc-fancy-area/preview'; import { UploadButton } from '@/lib/uploadthing'; +import { useChannelStreamKey } from '@/lib/hooks/useChannelStreamKey'; import { useOwnedChannels } from '@/lib/hooks/useUserList'; import { ChannelSelect } from '@/components/app/ChannelSelect/ChannelSelect'; import { useRouter } from 'next/navigation'; @@ -112,7 +113,6 @@ export default function ChannelSettingsClient({ isPersonal, }: ChannelSettingsClientProps) { const confirm = useConfirm(); - const [streamKey, setStreamKey] = useState(channel.streamKey?.key || ''); const [keyVisible, setKeyVisible] = useState(false); const [copied, setCopied] = useState({ streamKey: false, @@ -123,6 +123,11 @@ export default function ChannelSettingsClient({ const [uploadError, setUploadError] = useState(null); const [region, setRegion] = useState('hq'); const channelList = useOwnedChannels(); + const { + streamKey, + isRegenerating: isRegeneratingStreamKey, + regenerateStreamKey, + } = useChannelStreamKey(channel.name, channel.streamKey?.key); const router = useRouter(); const channelSettingsFormRef = useRef(null); @@ -185,22 +190,11 @@ export default function ChannelSettingsClient({ } }; - const regenerateStreamKey = async () => { + const handleRegenerateStreamKey = async () => { try { - const response = await fetch('/api/rtmp/streamKey', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ channel: channel.name }), - }); - - if (response.ok) { - const data = await response.json(); - setStreamKey(data.key); - toast.success('Stream key regenerated successfully'); - } else { - toast.error('Failed to regenerate stream key'); - } - } catch (error) { + await regenerateStreamKey(); + toast.success('Stream key regenerated successfully'); + } catch { toast.error('Failed to regenerate stream key'); } }; @@ -247,6 +241,7 @@ export default function ChannelSettingsClient({
c.channel)} + includeCreate value={channel.name} onSelect={(value) => { if (value === 'create') { @@ -561,7 +556,12 @@ export default function ChannelSettingsClient({ )}
- + } + description={getStreamKeyErrorDescription(streamKeyError.message)} + icon={CircleAlert} + title="Could not load the stream key" + tone="destructive" + /> + ) : null} + + {primaryIssue ? ( + + {!isSessionActive && primaryIssue.context === 'preview' ? ( + + ) : null} + + {!isSessionActive && + primaryIssue.context !== 'warning' && + primaryIssue.context !== 'preview' ? ( + + ) : null} + + {primaryIssue.context === 'switch' && isLive ? ( + + ) : null} + + {isSessionActive && primaryIssue.context !== 'warning' ? ( + + ) : null} + + } + description={primaryIssue.description} + icon={primaryIssue.tone === 'warning' ? AlertTriangle : CircleAlert} + title={primaryIssue.title} + tone={primaryIssue.tone} + /> + ) : null} + + )} + +
+
+
+
+
+ +
+ + +
+ + {!hasChannels && !isLoadingChannels ? ( +

Create a channel to stream.

+ ) : null} +
+ + {/* Right: Actions */} +
+ {!isSessionActive ? ( +
+ + + {hasPreview ? ( + + ) : null} + + +
+ ) : ( +
+ + + +
+ )} +
+
+
+ + ); +} + +function AlertCard({ actions, description, icon: Icon, title, tone }: AlertCardProps) { + const isWarning = tone === 'warning'; + + return ( + + +
+ +
+

{title}

+

{description}

+
+
+ {actions ?
{actions}
: null} +
+
+ ); +} + +function getStreamKeyErrorDescription(message: string) { + if (message.toLowerCase().includes('unauthorized')) { + return 'You no longer have permission to stream to this channel. Try another channel or sign in again.'; + } + + if (message.toLowerCase().includes('not found')) { + return 'This channel does not have a valid stream key yet. Regenerate it in channel settings, then retry.'; + } + + return 'Refresh the page and try again. If it keeps failing, check channel settings and server config.'; +} + +type AlertCardProps = { + actions?: ReactNode; + description: string; + icon: LucideIcon; + title: string; + tone: 'warning' | 'destructive'; +}; diff --git a/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx index ec1ddb8..48720b8 100644 --- a/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx +++ b/apps/web/src/components/app/ChannelSelect/ChannelSelect.tsx @@ -1,8 +1,7 @@ -'use client' +'use client'; -import type { Channel } from "@hctv/db"; -import * as React from 'react'; import { Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { Select, SelectContent, @@ -11,13 +10,21 @@ import { SelectValue, } from '@/components/ui/select'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import type { Channel } from '@hctv/db'; export function ChannelSelect(props: Props) { - const { channelList } = props; + const { + channelList, + disabled = false, + includeCreate = false, + placeholder = 'Channel', + triggerClassName, + } = props; + return ( - + + {channelList.map((channel) => ( @@ -25,15 +32,22 @@ export function ChannelSelect(props: Props) {
- {channel.name[0]} + {channel.name[0]?.toUpperCase()}
{channel.name}
))} - } className='h-11'> - Create Channel - + {includeCreate ? ( + } + className="h-11" + > + Create Channel + + ) : null}
); @@ -42,5 +56,9 @@ export function ChannelSelect(props: Props) { interface Props { channelList: Channel[]; value?: string; + disabled?: boolean; + includeCreate?: boolean; onSelect: (value: string) => void; -} \ No newline at end of file + placeholder?: string; + triggerClassName?: string; +} diff --git a/apps/web/src/components/app/NavBar/NavBar.tsx b/apps/web/src/components/app/NavBar/NavBar.tsx index 102a2d5..1914bfb 100644 --- a/apps/web/src/components/app/NavBar/NavBar.tsx +++ b/apps/web/src/components/app/NavBar/NavBar.tsx @@ -15,7 +15,18 @@ import { logout } from '@/lib/auth/actions'; import { useSession } from '@/lib/providers/SessionProvider'; import Link from 'next/link'; import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher'; -import { IdCard, Shield, Settings, Users, PenSquare, LogOut, Code, Github, Heart } from 'lucide-react'; +import { + IdCard, + Shield, + Settings, + Users, + PenSquare, + LogOut, + Code, + Github, + Heart, + Radio, +} from 'lucide-react'; import { SidebarTrigger } from '@/components/ui/sidebar'; import Image from 'next/image'; import Logo from '@/lib/assets/logo.webp'; @@ -52,6 +63,16 @@ export default function Navbar(props: Props) { {/* Right Side Items */}
+ {user && ( + + + + )} + {props.editLivestream &&
{props.editLivestream}
} {user ? ( diff --git a/apps/web/src/lib/hooks/useChannelStreamKey.ts b/apps/web/src/lib/hooks/useChannelStreamKey.ts new file mode 100644 index 0000000..16c55cd --- /dev/null +++ b/apps/web/src/lib/hooks/useChannelStreamKey.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useCallback } from 'react'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; + +interface StreamKeyResponse { + key: string; +} + +async function parseStreamKeyResponse(response: Response): Promise { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || 'Failed to load stream key'); + } + + return response.json(); +} + +async function fetchStreamKey( + [url, channelName]: readonly [string, string] +): Promise { + const response = await fetch(`${url}?channel=${encodeURIComponent(channelName)}`); + return parseStreamKeyResponse(response); +} + +async function regenerateStreamKey( + url: string, + { arg: channelName }: { arg: string } +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ channel: channelName }), + }); + + return parseStreamKeyResponse(response); +} + +export function useChannelStreamKey(channelName?: string, initialKey?: string | null) { + const swrKey = channelName ? (['/api/rtmp/streamKey', channelName] as const) : null; + const { data, error, isLoading, isValidating, mutate } = useSWR( + swrKey, + fetchStreamKey, + { + fallbackData: initialKey ? { key: initialKey } : undefined, + revalidateOnFocus: false, + } + ); + const { trigger, isMutating } = useSWRMutation('/api/rtmp/streamKey', regenerateStreamKey); + + const refreshStreamKey = useCallback(async () => { + if (!channelName) { + return undefined; + } + + return mutate(); + }, [channelName, mutate]); + + const handleRegenerateStreamKey = useCallback(async () => { + if (!channelName) { + throw new Error('Select a channel before regenerating its stream key'); + } + + const nextStreamKey = await trigger(channelName); + await mutate(nextStreamKey, { revalidate: false }); + return nextStreamKey.key; + }, [channelName, mutate, trigger]); + + return { + streamKey: data?.key ?? initialKey ?? '', + error, + isLoading, + isRefreshing: isValidating && !isLoading, + isRegenerating: isMutating, + refreshStreamKey, + regenerateStreamKey: handleRegenerateStreamKey, + }; +} diff --git a/apps/web/src/lib/hooks/useScreensharePublisher.ts b/apps/web/src/lib/hooks/useScreensharePublisher.ts new file mode 100644 index 0000000..d17f0f7 --- /dev/null +++ b/apps/web/src/lib/hooks/useScreensharePublisher.ts @@ -0,0 +1,440 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client'; +import type { MediaMTXRegion } from '@/lib/utils/mediamtx/regions'; +import MediaMTXWebRTCPublisher from '@/lib/utils/mediamtx/webrtc'; + +const HLS_COMPATIBLE_VIDEO_CODECS = [ + ['h264', 'h264/90000'], + ['vp9', 'vp9/90000'], + ['av1', 'av1/90000'], + ['h265', 'h265/90000'], +] as const; + +const DISPLAY_MEDIA_OPTIONS: ScreenCaptureOptions = { + video: true, + audio: true, + monitorTypeSurfaces: 'include', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + systemAudio: 'include', +}; + +export function useScreensharePublisher({ + channelName, + region, + streamKey, +}: UseScreensharePublisherOptions) { + const previewRef = useRef(null); + const captureStreamRef = useRef(null); + const captureCleanupRef = useRef<(() => void) | null>(null); + const publisherRef = useRef(null); + const [publishState, setPublishState] = useState('idle'); + const [hasPreview, setHasPreview] = useState(false); + const [issue, setIssue] = useState(null); + const browserWarning = useMemo(() => getBrowserWarning(), []); + + const setPreviewStream = useCallback((stream: MediaStream | null) => { + if (previewRef.current) { + previewRef.current.srcObject = stream; + } + }, []); + + const detachCaptureCleanup = useCallback(() => { + captureCleanupRef.current?.(); + captureCleanupRef.current = null; + }, []); + + const clearCaptureStream = useCallback(() => { + detachCaptureCleanup(); + stopTracks(captureStreamRef.current); + captureStreamRef.current = null; + setHasPreview(false); + setPreviewStream(null); + }, [detachCaptureCleanup, setPreviewStream]); + + const closePublisher = useCallback(() => { + const publisher = publisherRef.current; + + publisherRef.current = null; + publisher?.close(); + }, []); + + const disposeCurrentSession = useCallback(() => { + closePublisher(); + clearCaptureStream(); + }, [clearCaptureStream, closePublisher]); + + const stopPublishing = useCallback(() => { + disposeCurrentSession(); + setIssue(null); + setPublishState('idle'); + }, [disposeCurrentSession]); + + const attachCaptureStopListener = useCallback( + (stream: MediaStream) => { + const [videoTrack] = stream.getVideoTracks(); + + if (!videoTrack) { + captureCleanupRef.current = null; + return; + } + + const handleEnded = () => { + stopPublishing(); + }; + + videoTrack.addEventListener('ended', handleEnded); + captureCleanupRef.current = () => { + videoTrack.removeEventListener('ended', handleEnded); + }; + }, + [stopPublishing] + ); + + const commitCaptureStream = useCallback( + (nextStream: MediaStream) => { + const previousStream = captureStreamRef.current; + + detachCaptureCleanup(); + captureStreamRef.current = nextStream; + setHasPreview(true); + setPreviewStream(nextStream); + attachCaptureStopListener(nextStream); + stopTracks(previousStream); + }, + [attachCaptureStopListener, detachCaptureCleanup, setPreviewStream] + ); + + const previewSource = useCallback(async () => { + try { + setIssue(null); + setPublishState('previewing'); + + const stream = await requestCaptureStream(); + + commitCaptureStream(stream); + setPublishState('preview'); + } catch (err) { + setPublishState(captureStreamRef.current ? 'preview' : 'idle'); + setIssue(classifyPublisherIssue(err, 'preview')); + } + }, [commitCaptureStream]); + + const startPublishing = useCallback(async () => { + if (!channelName) { + setIssue({ + context: 'start', + description: 'Pick a channel first so we know where to publish.', + title: 'Choose a channel before starting', + tone: 'warning', + }); + return; + } + + if (!streamKey) { + setIssue({ + context: 'start', + description: 'Wait for the stream key to load, then try starting again.', + title: 'Stream key is still unavailable', + tone: 'warning', + }); + return; + } + + try { + setIssue(null); + setPublishState('connecting'); + + const videoCodec = await getPreferredVideoCodec(); + let stream = captureStreamRef.current; + + if (!stream) { + stream = await requestCaptureStream(); + commitCaptureStream(stream); + } + + const publisher = new MediaMTXWebRTCPublisher({ + url: getWhipUrl(channelName, region), + stream, + videoCodec, + videoBitrate: 2000, + audioCodec: 'opus', + audioBitrate: 64, + audioVoice: true, + user: 'user', + pass: streamKey, + onConnected: () => { + if (publisherRef.current !== publisher) { + return; + } + + setPublishState('live'); + }, + onError: (message) => { + if (publisherRef.current !== publisher) { + return; + } + + setIssue(classifyPublisherIssue(message, 'publish')); + setPublishState('connecting'); + }, + }); + + publisherRef.current = publisher; + } catch (err) { + closePublisher(); + setPublishState(captureStreamRef.current ? 'preview' : 'idle'); + setIssue(classifyPublisherIssue(err, 'start')); + } + }, [channelName, closePublisher, commitCaptureStream, region, streamKey]); + + const changeSource = useCallback(async () => { + const publisher = publisherRef.current; + + if (!publisher) { + return; + } + + let nextStream: MediaStream | null = null; + + try { + setIssue(null); + setPublishState('switching'); + + nextStream = await requestCaptureStream(); + await publisher.replaceStream(nextStream); + commitCaptureStream(nextStream); + setPublishState('live'); + } catch (err) { + stopTracks(nextStream); + setPublishState(publisherRef.current ? 'live' : 'idle'); + setIssue(classifyPublisherIssue(err, 'switch')); + } + }, [commitCaptureStream]); + + useEffect(() => { + return () => { + disposeCurrentSession(); + }; + }, [disposeCurrentSession]); + + return { + browserWarning, + changeSource, + hasPreview, + issue, + isLive: publishState === 'live', + isPreviewReady: publishState === 'preview', + isPreviewingSource: publishState === 'previewing', + isSessionActive: + publishState === 'connecting' || publishState === 'live' || publishState === 'switching', + isStarting: publishState === 'connecting', + isSwitchingSource: publishState === 'switching', + publishState, + previewRef, + previewSource, + startPublishing, + stopPublishing, + }; +} + +async function requestCaptureStream() { + return navigator.mediaDevices.getDisplayMedia(DISPLAY_MEDIA_OPTIONS as DisplayMediaStreamOptions); +} + +function getWhipUrl(channelName: string, region: MediaMTXRegion) { + const { whip } = getMediamtxClientEnvs(region); + + return `${whip.replace(/\/$/, '')}/${encodeURIComponent(channelName)}/whip`; +} + +function stopTracks(stream: MediaStream | null) { + stream?.getTracks().forEach((track) => track.stop()); +} + +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +function classifyPublisherIssue(error: unknown, context: PublisherIssueContext): PublisherIssue { + const message = getErrorMessage( + error, + context === 'switch' + ? 'Failed to change screenshare source' + : context === 'preview' + ? 'Failed to preview the selected source' + : 'Failed to start publishing' + ); + const normalizedMessage = message.toLowerCase(); + + if (normalizedMessage.includes('notallowederror') || normalizedMessage.includes('permission')) { + return { + context, + description: + context === 'switch' + ? 'Choose a new tab, window, or display in the browser picker to continue the broadcast.' + : context === 'preview' + ? 'Approve the browser screen-share prompt so we can load your preview.' + : 'Approve the browser screen-share prompt, then try again.', + title: + context === 'switch' + ? 'Source switch was cancelled or blocked' + : context === 'preview' + ? 'Preview permission was denied' + : 'Screen-share permission was denied', + tone: 'warning', + }; + } + + if (normalizedMessage.includes('notfounderror')) { + return { + context, + description: + 'Open the window or tab you want to capture, then retry the screen-share picker.', + title: 'No capturable source was found', + tone: 'warning', + }; + } + + if ( + normalizedMessage.includes('getdisplaymedia') || + normalizedMessage.includes('secure context') || + normalizedMessage.includes('browser environment') + ) { + return { + context, + description: + 'Use HackClub.tv over HTTPS or localhost in a Chromium-based browser, then try again.', + title: 'This browser or page cannot start screen sharing', + tone: 'destructive', + }; + } + + if (normalizedMessage.includes('hls-compatible webrtc video codec')) { + return { + context, + description: + 'Switch to a Chromium-based browser. Firefox and Safari can expose codecs that our ingest pipeline cannot use reliably yet.', + title: 'This browser cannot publish a compatible stream codec', + tone: 'destructive', + }; + } + + if (normalizedMessage.includes('invalid stream key') || normalizedMessage.includes('403')) { + return { + context, + description: + 'Refresh the page or regenerate the stream key in channel settings if this keeps happening.', + title: 'The ingest server rejected your stream key', + tone: 'destructive', + }; + } + + if (normalizedMessage.includes('404')) { + return { + context, + description: + 'The selected ingest server may be misconfigured or offline. Try another server or retry in a moment.', + title: 'The selected ingest server could not be reached', + tone: 'destructive', + }; + } + + if (normalizedMessage.includes('retrying in some seconds')) { + return { + context, + description: + 'We are retrying automatically. Keep this page open, or stop and start again if it does not recover.', + title: 'Connection to the ingest server dropped', + tone: 'warning', + }; + } + + return { + context, + description: + context === 'switch' + ? 'Try choosing the source again. If it keeps failing, stop the stream and start a new session.' + : context === 'preview' + ? 'Try choosing the source again. If it keeps failing, reload the page or switch browsers.' + : 'Try again. If it keeps failing, switch servers or reload the page.', + title: + context === 'switch' + ? 'Could not switch the shared source' + : context === 'preview' + ? 'Could not load the preview' + : 'Could not start the stream', + tone: 'destructive', + }; +} + +function getBrowserWarning(): PublisherIssue | null { + if (typeof navigator === 'undefined') { + return null; + } + + const userAgent = navigator.userAgent.toLowerCase(); + const isChromium = + userAgent.includes('chrome') || userAgent.includes('chromium') || userAgent.includes('edg/'); + + if (isChromium) { + return null; + } + + return { + context: 'warning', + description: + 'You can still try this here, but screen capture and source switching are most reliable in Chrome or another Chromium-based browser.', + title: 'This browser is supported on a best-effort basis', + tone: 'warning', + }; +} + +async function getPreferredVideoCodec(): Promise { + const tempPc = new RTCPeerConnection(); + + try { + tempPc.addTransceiver('video', { direction: 'sendonly' }); + + const offer = await tempPc.createOffer(); + const sdp = offer.sdp?.toLowerCase() ?? ''; + + for (const [codec, needle] of HLS_COMPATIBLE_VIDEO_CODECS) { + if (sdp.includes(needle)) { + return codec; + } + } + } finally { + tempPc.close(); + } + + throw new Error( + 'This browser does not expose an HLS-compatible WebRTC video codec. MediaMTX HLS supports AV1, VP9, H265, and H264, but not VP8.' + ); +} + +type PublishState = 'idle' | 'previewing' | 'preview' | 'connecting' | 'live' | 'switching'; + +type UseScreensharePublisherOptions = { + channelName: string; + region: MediaMTXRegion; + streamKey?: string | null; +}; + +type PublisherIssue = { + context: PublisherIssueContext; + description: string; + title: string; + tone: 'warning' | 'destructive'; +}; + +type PublisherIssueContext = 'preview' | 'publish' | 'start' | 'switch' | 'warning'; + +type ScreenCaptureOptions = DisplayMediaStreamOptions & { + monitorTypeSurfaces?: 'include' | 'exclude'; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; + systemAudio?: 'include' | 'exclude'; +}; diff --git a/apps/web/src/lib/instrumentation/streamInfo.ts b/apps/web/src/lib/instrumentation/streamInfo.ts index 32684dd..39bdecf 100644 --- a/apps/web/src/lib/instrumentation/streamInfo.ts +++ b/apps/web/src/lib/instrumentation/streamInfo.ts @@ -90,7 +90,15 @@ export async function syncStream() { for (const r of regions) { const region = MEDIAMTX_SERVER_REGIONS[r]; - const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`); + if (!region.apiAuthHeader) { + throw new Error('MEDIAMTX_API_KEY is required when querying the MediaMTX API'); + } + + const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`, { + headers: { + Authorization: region.apiAuthHeader, + }, + }); if (!response.ok) { recordStreamSyncScrape(r, 'error'); diff --git a/apps/web/src/lib/utils/mediamtx/client.ts b/apps/web/src/lib/utils/mediamtx/client.ts index 73edd2c..edef96d 100644 --- a/apps/web/src/lib/utils/mediamtx/client.ts +++ b/apps/web/src/lib/utils/mediamtx/client.ts @@ -4,18 +4,37 @@ import { getEnv } from '@/lib/env'; export interface MediaMTXClientEnvs { publicUrl: string; ingestRoute: string; + whip: string; + whipEnabled: boolean; emoji: string; string: string; } +export interface MediaMTXClientRegionOption { + value: MediaMTXRegion; + emoji: string; + label: string; + whipEnabled: boolean; +} + export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs { const envs: Record = { hq: { publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!, ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!, + whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_HQ')!, + whipEnabled: false, emoji: 'πŸ‡ΊπŸ‡Έ', string: 'HQ Server A', }, + ethande: { + publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_ETHANDE')!, + ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ETHANDE')!, + whip: getEnv('NEXT_PUBLIC_MEDIAMTX_WHIP_ROUTE_ETHANDE')!, + whipEnabled: true, + emoji: 'πŸ‡©πŸ‡ͺ', + string: 'eth0\'s VPS', + }, }; const regionEnvs = envs[region]; @@ -27,3 +46,13 @@ export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXCl return regionEnvs; } +export function getMediamtxClientRegionOptions(): MediaMTXClientRegionOption[] { + return [ + { + value: 'hq', + emoji: 'πŸ‡ΊπŸ‡Έ', + label: 'HQ Server A', + whipEnabled: false, + }, + ]; +} diff --git a/apps/web/src/lib/utils/mediamtx/regions.ts b/apps/web/src/lib/utils/mediamtx/regions.ts index 4140a62..0a3a23a 100644 --- a/apps/web/src/lib/utils/mediamtx/regions.ts +++ b/apps/web/src/lib/utils/mediamtx/regions.ts @@ -1 +1 @@ -export type MediaMTXRegion = 'hq'; +export type MediaMTXRegion = 'hq' | 'ethande'; diff --git a/apps/web/src/lib/utils/mediamtx/server.ts b/apps/web/src/lib/utils/mediamtx/server.ts index 8866c46..73033b2 100644 --- a/apps/web/src/lib/utils/mediamtx/server.ts +++ b/apps/web/src/lib/utils/mediamtx/server.ts @@ -2,11 +2,13 @@ import { MediaMTXRegion } from './regions'; export interface MediaMTXEnvs { apiUrl: string; + apiAuthHeader?: string; } export const MEDIAMTX_SERVER_REGIONS: Record = { hq: { apiUrl: process.env.MEDIAMTX_API_HQ!, + apiAuthHeader: getMediamtxApiAuthHeader(), }, }; @@ -19,3 +21,13 @@ export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs { return envs; } + +function getMediamtxApiAuthHeader() { + const apiKey = process.env.MEDIAMTX_API_KEY; + + if (!apiKey) { + return undefined; + } + + return `Basic ${Buffer.from(`hctv-api:${apiKey}`).toString('base64')}`; +} diff --git a/apps/web/src/lib/utils/mediamtx/webrtc.ts b/apps/web/src/lib/utils/mediamtx/webrtc.ts new file mode 100644 index 0000000..295dba0 --- /dev/null +++ b/apps/web/src/lib/utils/mediamtx/webrtc.ts @@ -0,0 +1,531 @@ +// based off https://github.com/bluenviron/mediamtx/blob/v1.17.1/internal/servers/webrtc/publisher.js +// modified by codex to typescript and to suit the platform's needs! +export type OnError = (err: string) => void; +export type OnConnected = () => void; + +export type PublisherState = 'running' | 'restarting' | 'closed'; +type MediaKind = 'audio' | 'video'; + +export type PublisherConfig = { + url: string; + user?: string; + pass?: string; + token?: string; + stream: MediaStream; + videoCodec: string; + videoBitrate: number; + audioCodec: string; + audioBitrate: number; + audioVoice: boolean; + onError?: OnError; + onConnected?: OnConnected; +}; + +type OfferData = { + iceUfrag: string; + icePwd: string; + medias: string[]; +}; + +type ParsedIceServer = RTCIceServer & { + credentialType?: 'password'; +}; + +/** WebRTC/WHIP publisher. */ +export class MediaMTXWebRTCPublisher { + private readonly retryPause = 2000; + private readonly conf: PublisherConfig; + private stream: MediaStream; + private state: PublisherState = 'running'; + private restartTimeout: ReturnType | null = null; + private pc: RTCPeerConnection | null = null; + private offerData: OfferData | null = null; + private sessionUrl: string | null = null; + private queuedCandidates: RTCIceCandidate[] = []; + private trackSenders: Partial> = {}; + + constructor(conf: PublisherConfig) { + if ( + typeof window === 'undefined' || + typeof RTCPeerConnection === 'undefined' || + typeof MediaStream === 'undefined' + ) { + throw new Error('MediaMTXWebRTCPublisher can only be used in a browser environment.'); + } + + this.conf = conf; + this.stream = conf.stream; + this.start(); + } + + close = (): void => { + this.state = 'closed'; + + if (this.restartTimeout !== null) { + clearTimeout(this.restartTimeout); + } + + this.resetConnection(); + this.disposeSession(); + }; + + replaceStream = async (stream: MediaStream): Promise => { + if (this.state !== 'running' || this.pc === null) { + throw new Error('publisher is not running'); + } + + const nextTracks: Record = { + audio: stream.getAudioTracks()[0] ?? null, + video: stream.getVideoTracks()[0] ?? null, + }; + + await Promise.all( + (['audio', 'video'] as const).map(async (kind) => { + const sender = this.trackSenders[kind]; + + if (!sender) { + return; + } + + await sender.replaceTrack(nextTracks[kind]); + }) + ); + + this.stream = stream; + }; + + private resetConnection(): void { + if (this.pc !== null) { + this.pc.close(); + this.pc = null; + } + + this.offerData = null; + this.queuedCandidates = []; + this.trackSenders = {}; + } + + private disposeSession(): void { + if (this.sessionUrl !== null) { + void fetch(this.sessionUrl, { + method: 'DELETE', + }); + this.sessionUrl = null; + } + } + + static #unquoteCredential(value: string): string { + return JSON.parse(`"${value}"`) as string; + } + + static #linkToIceServers(links: string | null): ParsedIceServer[] { + if (links === null) { + return []; + } + + return links.split(', ').flatMap((link) => { + const match = link.match( + /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i + ); + + if (!match) { + return []; + } + + const iceServer: ParsedIceServer = { + urls: [match[1]], + }; + + if (match[3] !== undefined && match[4] !== undefined) { + iceServer.username = this.#unquoteCredential(match[3]); + iceServer.credential = this.#unquoteCredential(match[4]); + iceServer.credentialType = 'password'; + } + + return [iceServer]; + }); + } + + static #parseOffer(offer: string): OfferData { + const parsedOffer: OfferData = { + iceUfrag: '', + icePwd: '', + medias: [], + }; + + for (const line of offer.split('\r\n')) { + if (line.startsWith('m=')) { + parsedOffer.medias.push(line.slice('m='.length)); + } else if (parsedOffer.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) { + parsedOffer.iceUfrag = line.slice('a=ice-ufrag:'.length); + } else if (parsedOffer.icePwd === '' && line.startsWith('a=ice-pwd:')) { + parsedOffer.icePwd = line.slice('a=ice-pwd:'.length); + } + } + + return parsedOffer; + } + + static #generateSdpFragment(offerData: OfferData, candidates: RTCIceCandidate[]): string { + const candidatesByMedia: Record = {}; + + for (const candidate of candidates) { + const mid = candidate.sdpMLineIndex; + if (mid === null) { + continue; + } + + if (candidatesByMedia[mid] === undefined) { + candidatesByMedia[mid] = []; + } + candidatesByMedia[mid].push(candidate); + } + + let fragment = `a=ice-ufrag:${offerData.iceUfrag}\r\n` + `a=ice-pwd:${offerData.icePwd}\r\n`; + + let mid = 0; + + for (const media of offerData.medias) { + if (candidatesByMedia[mid] !== undefined) { + fragment += `m=${media}\r\n` + `a=mid:${mid}\r\n`; + + for (const candidate of candidatesByMedia[mid]) { + fragment += `a=${candidate.candidate}\r\n`; + } + } + mid++; + } + + return fragment; + } + + static #setCodec(section: string, codec: string): string { + const normalizedCodec = codec.toLowerCase(); + const lines = section.split('\r\n'); + const filteredLines: string[] = []; + const payloadFormats: string[] = []; + + for (const line of lines) { + if (!line.startsWith('a=rtpmap:')) { + filteredLines.push(line); + } else if (line.toLowerCase().includes(normalizedCodec)) { + payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]); + filteredLines.push(line); + } + } + + const rewrittenLines: string[] = []; + let firstLine = true; + + for (const line of filteredLines) { + if (firstLine) { + firstLine = false; + rewrittenLines.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' ')); + } else if (line.startsWith('a=fmtp:')) { + if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) { + rewrittenLines.push(line); + } + } else if (line.startsWith('a=rtcp-fb:')) { + if (payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])) { + rewrittenLines.push(line); + } + } else { + rewrittenLines.push(line); + } + } + + return rewrittenLines.join('\r\n'); + } + + static #setVideoBitrate(section: string, bitrate: number): string { + let lines = section.split('\r\n'); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('c=')) { + lines = [ + ...lines.slice(0, i + 1), + `b=TIAS:${(bitrate * 1024).toString()}`, + ...lines.slice(i + 1), + ]; + break; + } + } + + return lines.join('\r\n'); + } + + static #setAudioBitrate(section: string, bitrate: number, voice: boolean): string { + let opusPayloadFormat = ''; + const lines = section.split('\r\n'); + + for (const line of lines) { + if (line.startsWith('a=rtpmap:') && line.toLowerCase().includes('opus/')) { + opusPayloadFormat = line.slice('a=rtpmap:'.length).split(' ')[0]; + break; + } + } + + if (opusPayloadFormat === '') { + return section; + } + + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) { + if (voice) { + lines[i] = + `a=fmtp:${opusPayloadFormat} minptime=10;useinbandfec=1;maxaveragebitrate=${(bitrate * 1024).toString()}`; + } else { + lines[i] = + `a=fmtp:${opusPayloadFormat} maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=${(bitrate * 1024).toString()}`; + } + } + } + + return lines.join('\r\n'); + } + + static #editOffer( + sdp: string, + videoCodec: string, + audioCodec: string, + audioBitrate: number, + audioVoice: boolean + ): string { + const sections = sdp.split('m='); + + for (let i = 0; i < sections.length; i++) { + if (sections[i].startsWith('video')) { + sections[i] = this.#setCodec(sections[i], videoCodec); + } else if (sections[i].startsWith('audio')) { + sections[i] = this.#setAudioBitrate( + this.#setCodec(sections[i], audioCodec), + audioBitrate, + audioVoice + ); + } + } + + return sections.join('m='); + } + + static #editAnswer(sdp: string, videoBitrate: number): string { + const sections = sdp.split('m='); + + for (let i = 0; i < sections.length; i++) { + if (sections[i].startsWith('video')) { + sections[i] = this.#setVideoBitrate(sections[i], videoBitrate); + } + } + + return sections.join('m='); + } + + private async start(): Promise { + try { + const iceServers = await this.requestIceServers(); + const offer = await this.setupPeerConnection(iceServers); + const answer = await this.sendOffer(offer); + await this.setAnswer(answer); + } catch (error) { + this.handleError(error instanceof Error ? error.message : String(error)); + } + } + + private handleError(err: string): void { + if (this.state === 'running') { + this.resetConnection(); + this.disposeSession(); + this.state = 'restarting'; + + this.restartTimeout = setTimeout(() => { + this.restartTimeout = null; + this.state = 'running'; + void this.start(); + }, this.retryPause); + + this.conf.onError?.(`${err}, retrying in some seconds`); + } + } + + private authHeader(): HeadersInit { + if (this.conf.user !== undefined && this.conf.user !== '') { + const credentials = btoa(`${this.conf.user}:${this.conf.pass ?? ''}`); + return { Authorization: `Basic ${credentials}` }; + } + if (this.conf.token !== undefined && this.conf.token !== '') { + return { Authorization: `Bearer ${this.conf.token}` }; + } + return {}; + } + + private async requestIceServers(): Promise { + const response = await fetch(this.conf.url, { + method: 'OPTIONS', + headers: { + ...this.authHeader(), + }, + }); + + return MediaMTXWebRTCPublisher.#linkToIceServers(response.headers.get('Link')); + } + + private async setupPeerConnection(iceServers: RTCIceServer[]): Promise { + if (this.state !== 'running') { + throw new Error('closed'); + } + + this.pc = new RTCPeerConnection({ + iceServers, + }); + + this.pc.onicecandidate = (event) => this.onLocalCandidate(event); + this.pc.onconnectionstatechange = () => this.onConnectionState(); + this.trackSenders = {}; + + this.stream.getTracks().forEach((track) => { + const sender = this.pc?.addTrack(track, this.stream); + + if (sender && (track.kind === 'audio' || track.kind === 'video')) { + this.trackSenders[track.kind] = sender; + } + }); + + const offer = await this.pc.createOffer(); + if (!offer.sdp) { + throw new Error('missing offer SDP'); + } + + this.offerData = MediaMTXWebRTCPublisher.#parseOffer(offer.sdp); + await this.pc.setLocalDescription(offer); + + return offer.sdp; + } + + private async sendOffer(offer: string): Promise { + if (this.state !== 'running') { + throw new Error('closed'); + } + + const editedOffer = MediaMTXWebRTCPublisher.#editOffer( + offer, + this.conf.videoCodec, + this.conf.audioCodec, + this.conf.audioBitrate, + this.conf.audioVoice + ); + + const response = await fetch(this.conf.url, { + method: 'POST', + headers: { + ...this.authHeader(), + 'Content-Type': 'application/sdp', + }, + body: editedOffer, + }); + + switch (response.status) { + case 201: + break; + case 400: { + const errorBody = (await response.json()) as { error?: string }; + throw new Error(errorBody.error ?? 'bad request'); + } + default: + throw new Error(`bad status code ${response.status}`); + } + + const location = response.headers.get('location'); + if (!location) { + throw new Error('missing session location'); + } + + this.sessionUrl = new URL(location, this.conf.url).toString(); + + return response.text(); + } + + private async setAnswer(answer: string): Promise { + if (this.state !== 'running') { + throw new Error('closed'); + } + + const peerConnection = this.pc; + if (peerConnection === null) { + throw new Error('missing peer connection'); + } + + const editedAnswer = MediaMTXWebRTCPublisher.#editAnswer(answer, this.conf.videoBitrate); + + await peerConnection.setRemoteDescription( + new RTCSessionDescription({ + type: 'answer', + sdp: editedAnswer, + }) + ); + + if (this.state !== 'running') { + return; + } + + if (this.queuedCandidates.length !== 0) { + this.sendLocalCandidates(this.queuedCandidates); + this.queuedCandidates = []; + } + } + + private onLocalCandidate(event: RTCPeerConnectionIceEvent): void { + if (this.state !== 'running') { + return; + } + + if (event.candidate !== null) { + if (this.sessionUrl === null) { + this.queuedCandidates.push(event.candidate); + } else { + this.sendLocalCandidates([event.candidate]); + } + } + } + + private sendLocalCandidates(candidates: RTCIceCandidate[]): void { + if (this.sessionUrl === null || this.offerData === null) { + return; + } + + void fetch(this.sessionUrl, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/trickle-ice-sdpfrag', + 'If-Match': '*', + }, + body: MediaMTXWebRTCPublisher.#generateSdpFragment(this.offerData, candidates), + }) + .then((response) => { + switch (response.status) { + case 204: + break; + case 404: + throw new Error('stream not found'); + default: + throw new Error(`bad status code ${response.status}`); + } + }) + .catch((error) => { + this.handleError(error instanceof Error ? error.message : String(error)); + }); + } + + private onConnectionState(): void { + if (this.state !== 'running' || this.pc === null) { + return; + } + + if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') { + this.handleError('peer connection closed'); + } else if (this.pc.connectionState === 'connected') { + this.conf.onConnected?.(); + } + } +} + +export default MediaMTXWebRTCPublisher; diff --git a/apps/web/src/lib/workers/worker/thumbnails.ts b/apps/web/src/lib/workers/worker/thumbnails.ts index d63f6d2..c41bab9 100644 --- a/apps/web/src/lib/workers/worker/thumbnails.ts +++ b/apps/web/src/lib/workers/worker/thumbnails.ts @@ -45,7 +45,8 @@ export async function registerThumbnailWorker(): Promise { ); return { success: true }; } catch (ffmpegError) { - console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError); + // commenting since its mostly due to the fact that the stream is likely offline + // console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError); return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) }; } } catch (e) { diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 87f8588..db90f5d 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -28,6 +28,7 @@ services: ports: - 8890:8890/udp - 8891:8888 + - 8889:8889 - 9997:9997 - 9998:9998 volumes: diff --git a/dev/mediamtx.yml b/dev/mediamtx.yml index 4a8a231..a9bb70c 100644 --- a/dev/mediamtx.yml +++ b/dev/mediamtx.yml @@ -11,6 +11,8 @@ hlsSegmentDuration: 2s hlsPartDuration: 500ms hlsSegmentCount: 10 +webrtc: yes + authMethod: http authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish diff --git a/docker/mediamtx/mediamtx.yml b/docker/mediamtx/mediamtx.yml index 3a1395e..284bae8 100644 --- a/docker/mediamtx/mediamtx.yml +++ b/docker/mediamtx/mediamtx.yml @@ -11,9 +11,15 @@ hlsSegmentDuration: 2s hlsPartDuration: 1s hlsSegmentCount: 10 +webrtc: yes +webrtcAddress: :8889 +webrtcLocalUDPAddress: :8189 +webrtcAdditionalHosts: [] + authMethod: http -authHTTPAddress: http://hctv:3000/api/mediamtx/publish +authHTTPAddress: https://hackclub.tv/api/mediamtx/publish api: yes +apiAddress: 0.0.0.0:9997 metrics: yes metricsAddress: :9998 diff --git a/docker/mediamtx/mirror/.env.example b/docker/mediamtx/mirror/.env.example new file mode 100644 index 0000000..f0ed859 --- /dev/null +++ b/docker/mediamtx/mirror/.env.example @@ -0,0 +1,12 @@ +ACME_EMAIL=ops@hackclub.tv + +# public hostnames and stuff +MEDIAMTX_HLS_HOST=hls.hackclub.tv +MEDIAMTX_WEBRTC_HOST=whip.hackclub.tv +MEDIAMTX_API_HOST=mmtxapi.hackclub.tv + +# public ip for webrtc stuff +MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS=203.0.113.10 + +# mediamtx publish route on hctv +MEDIAMTX_AUTH_HTTP_ADDRESS=https://hackclub.tv/api/mediamtx/publish diff --git a/docker/mediamtx/mirror/docker-compose.yml b/docker/mediamtx/mirror/docker-compose.yml new file mode 100644 index 0000000..d2371df --- /dev/null +++ b/docker/mediamtx/mirror/docker-compose.yml @@ -0,0 +1,63 @@ +services: + traefik: + image: traefik:v3.5 + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.srt.address=:8890/udp + - --entrypoints.webrtc-ice.address=:8189/udp + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + ports: + - 80:80 + - 443:443 + - 8890:8890/udp + - 8189:8189/udp + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + restart: unless-stopped + + mediamtx: + image: bluenviron/mediamtx:1 + volumes: + - ./mediamtx.yml:/mediamtx.yml:ro + environment: + MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_ADDITIONAL_HOSTS} + MTX_AUTHHTTPADDRESS: ${MEDIAMTX_AUTH_HTTP_ADDRESS} + labels: + - traefik.enable=true + + - traefik.http.routers.mediamtx-hls.rule=Host(`${MEDIAMTX_HLS_HOST}`) + - traefik.http.routers.mediamtx-hls.entrypoints=websecure + - traefik.http.routers.mediamtx-hls.tls.certresolver=letsencrypt + - traefik.http.routers.mediamtx-hls.service=mediamtx-hls + - traefik.http.services.mediamtx-hls.loadbalancer.server.port=8888 + + - traefik.http.routers.mediamtx-webrtc.rule=Host(`${MEDIAMTX_WEBRTC_HOST}`) + - traefik.http.routers.mediamtx-webrtc.entrypoints=websecure + - traefik.http.routers.mediamtx-webrtc.tls.certresolver=letsencrypt + - traefik.http.routers.mediamtx-webrtc.service=mediamtx-webrtc + - traefik.http.services.mediamtx-webrtc.loadbalancer.server.port=8889 + + - traefik.http.routers.mediamtx-api.rule=Host(`${MEDIAMTX_API_HOST}`) + - traefik.http.routers.mediamtx-api.entrypoints=websecure + - traefik.http.routers.mediamtx-api.tls.certresolver=letsencrypt + - traefik.http.routers.mediamtx-api.service=mediamtx-api + - traefik.http.services.mediamtx-api.loadbalancer.server.port=9997 + + - traefik.udp.routers.mediamtx-srt.entrypoints=srt + - traefik.udp.routers.mediamtx-srt.service=mediamtx-srt + - traefik.udp.services.mediamtx-srt.loadbalancer.server.port=8890 + + - traefik.udp.routers.mediamtx-webrtc-ice.entrypoints=webrtc-ice + - traefik.udp.routers.mediamtx-webrtc-ice.service=mediamtx-webrtc-ice + - traefik.udp.services.mediamtx-webrtc-ice.loadbalancer.server.port=8189 + restart: unless-stopped + +volumes: + letsencrypt: diff --git a/docker/mediamtx/mirror/mediamtx.yml b/docker/mediamtx/mirror/mediamtx.yml new file mode 100644 index 0000000..995a86b --- /dev/null +++ b/docker/mediamtx/mirror/mediamtx.yml @@ -0,0 +1,26 @@ +paths: + all: + source: publisher + +srt: yes +srtAddress: :8890 + +hls: yes +hlsVariant: lowLatency +hlsSegmentDuration: 2s +hlsPartDuration: 1s +hlsSegmentCount: 10 + +webrtc: yes +webrtcAddress: :8889 +webrtcLocalUDPAddress: :8189 +webrtcAdditionalHosts: [] + +authMethod: http +authHTTPAddress: https://hackclub.tv/api/mediamtx/publish + +api: yes +apiAddress: :9997 + +metrics: yes +metricsAddress: :9998