feat: streaminfo and thumbnail wiring

This commit is contained in:
2025-12-17 18:33:21 +01:00
parent 440eb407dd
commit 1ff51fad61
7 changed files with 64 additions and 98 deletions

View File

@@ -1,8 +1,10 @@
import { prisma, getRedisConnection } from '@hctv/db';
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { lucia } from '@hctv/auth';
export async function POST(request: NextRequest) {
const redis = getRedisConnection();
const body = await request.json();
const parsed = schema.safeParse(body);
@@ -12,35 +14,26 @@ export async function POST(request: NextRequest) {
}
const { action, protocol, path, password } = parsed.data;
if (action === 'publish' && protocol === 'srt') {
const redis = getRedisConnection();
const channelKey = await redis.get(`streamKey:${path}`)
const channelKey = await redis.get(`streamKey:${path}`);
if (channelKey) {
if (channelKey !== password) {
return new Response('invalid stream key', { status: 403 });
}
return new Response('youre in yay', { status: 200 });
} else {
const key = await prisma.streamKey.findFirst({
where: {
key: password,
channel: {
name: path,
}
},
include: {
channel: true,
},
});
if (!key) {
return new Response('invalid stream key', { status: 403 });
}
}
} else if (action === 'read' && protocol === 'hls') {
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
return new Response('authorized', { status: 200 });
}
const sessionExists = await redis.exists(`sessions:${password}`);
if (!sessionExists) {
return new Response('unauthorized', { status: 401 });
}
return new Response('authorized', { status: 200 });
}
return new Response('Request processed', { status: 200 });
return new Response('uhh', { status: 401 });
}
const schema = z.object({

View File

@@ -1,6 +1,7 @@
'use client';
import { useParams } from 'next/navigation';
import { useRef, useEffect } from 'react';
import {
MediaController,
MediaLoadingIndicator,
@@ -13,51 +14,40 @@ import {
MediaFullscreenButton,
} from 'media-chrome/react';
import HlsVideo from 'hls-video-element/react';
import { useSession } from '@/lib/providers/SessionProvider';
export default function StreamPlayer() {
const { username } = useParams();
const { session } = useSession();
const videoRef = useRef(null);
useEffect(() => {
if (videoRef.current && username) {
const user = 'skibiditoilet';
const credentials = btoa(`${user}:${session!.id}`);
// @ts-ignore
videoRef.current.config = {
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Authorization', `Basic ${credentials}`);
xhr.setRequestHeader('hi', 'afjlkafbjadlkfghbjlk');
},
lowLatencyMode: true,
debug: process.env.NODE_ENV === 'development',
};
// @ts-ignore
videoRef.current.src = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${username}/index.m3u8`;
}
}, [username]);
return (
<MediaController className="w-full aspect-video">
<HlsVideo
src={`${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${username}/index.m3u8`}
ref={videoRef}
slot="media"
crossOrigin="anonymous"
autoplay
config={{
xhrSetup: async (xhr: XMLHttpRequest, url: string) => {
const user = 'skibiditoilet';
const pass = getCookie('auth_session');
const credentials = btoa(`${user}:${pass}`);
xhr.setRequestHeader('Authorization', `Basic ${credentials}`);
},
lowLatencyMode: true,
liveSyncDurationCount: 1,
liveMaxLatencyDurationCount: 2,
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 1,
startLevel: -1,
maxBufferLength: 2,
maxMaxBufferLength: 4,
startFragPrefetch: true,
testBandwidth: false,
progressive: false,
maxBufferSize: 10 * 1000 * 1000,
maxBufferHole: 0.1,
highBufferWatchdogPeriod: 0.5,
nudgeOffset: 0.01,
nudgeMaxRetry: 3,
manifestLoadingTimeOut: 3000,
manifestLoadingMaxRetry: 3,
levelLoadingTimeOut: 3000,
fragLoadingTimeOut: 5000,
debug: process.env.NODE_ENV === 'development',
liveSyncDuration: 1,
liveMaxLatencyDuration: 3,
maxLiveSyncPlaybackRate: 1.5,
liveBackBufferLength: 0,
}}
/>
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
<MediaControlBar className="w-full px-2">
@@ -73,10 +63,3 @@ export default function StreamPlayer() {
</MediaController>
);
}
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()!.split(';').shift() || null;
return null;
}

View File

@@ -2,6 +2,7 @@ import { prisma } from '@hctv/db';
import { HttpFlv } from '../types/liveBackendJson';
import { getNotificationQueue } from '../workers';
import client from '../services/slackNotifier';
import type { paths } from '../types/mediamtx.d.ts';
export default async function runner() {
// if there are no users it explodes so yeah
@@ -48,28 +49,21 @@ export async function initializeStreamInfo(channelId?: string) {
export async function syncStream() {
try {
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
const response = await fetch(`${process.env.MEDIAMTX_API}/v3/paths/list?itemsPerPage=1000`);
if (!response.ok) {
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const httpFlv = data['http-flv'] as HttpFlv;
type ResponseType = paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
const data = await response.json() as ResponseType;
if (!httpFlv?.servers?.[0]?.applications) {
if (!data) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
const activeStreams = data.items!;
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
@@ -78,8 +72,7 @@ export async function syncStream() {
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter((c) => !c.publishing).length,
isLive: stream.ready,
});
}
@@ -99,7 +92,7 @@ export async function syncStream() {
}
for (const stream of activeStreams) {
if (stream.active) {
if (stream.ready) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
include: { channel: true },

View File

@@ -1,8 +1,6 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*
* committing because i cba to generate this on ci yet
*/
export interface paths {

View File

@@ -1,9 +1,9 @@
import { Worker } from 'bullmq';
import { getRedisConnection } from '@hctv/db';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
const pExec = promisify(exec);
import { exec as execCallback } from 'node:child_process';
const pExec = promisify(execCallback);
const globalForWorker = global as unknown as {
thumbnailWorker: Worker | null;
@@ -26,28 +26,24 @@ export async function registerThumbnailWorker(): Promise<void> {
try {
// this is totally unnecessary, but i'll keep it for security purposes.
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
const m3u8location = `/dev/shm/hls/${name}.m3u8`;
const m3u8location = `${process.env.NEXT_PUBLIC_MEDIAMTX_URL}/${name}/index.m3u8`;
const thumbDir = '/dev/shm/hctv-thumb';
if (!existsSync(m3u8location)) return;
if (!existsSync(thumbDir)) {
await pExec(`mkdir -p ${thumbDir}`);
}
// unnecessary for development, but maybe docker volumes mess with permissions in prod
// also ik it's not the best practice to use 777, but it'll be fiiiiiine
// await pExec('chown -R 777 /dev/shm/hctv-thumb');
exec(
`ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`,
(error) => {
if (error) {
console.error(`Error: ${error.message}`);
return { success: false, error: error.message };
}
}
);
return { success: true };
const header = `-headers "Authorization: Basic ${Buffer.from(`skibiditoilet:${process.env.MEDIAMTX_PUBLISH_KEY}`).toString('base64')}\r\n" `;
try {
await pExec(
`ffmpeg ${header} -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`
);
return { success: true };
} catch (ffmpegError) {
console.error(`FFmpeg error for ${name}:`, ffmpegError);
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
}
} catch (e) {
console.error('Slack notification failed:', e);
// @ts-ignore e is unknown

View File

@@ -20,5 +20,6 @@ services:
ports:
- 8890:8890/udp
- 8891:8888
- 9997:9997
volumes:
- ./mediamtx.yml:/mediamtx.yml

View File

@@ -8,4 +8,6 @@ srtAddress: :8890
hls: yes
authMethod: http
authHTTPAddress: http://192.168.1.47:3000/api/mediamtx/publish
authHTTPAddress: http://192.168.1.47:3000/api/mediamtx/publish
api: yes