Initial scaffold: React+TS+Vite frontend, FastAPI backend, config system

This commit is contained in:
root
2026-02-25 21:57:36 -06:00
commit 11eab66e9d
45 changed files with 4508 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
export interface AlertEvent {
type: 'alert';
camera: string;
label: string;
event_id: string;
has_snapshot: boolean;
has_clip: boolean;
timestamp: number;
}
export type EventHandler = (event: AlertEvent) => void;
export class AlertWebSocket {
private ws: WebSocket | null = null;
private handlers: Set<EventHandler> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private url: string;
constructor() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
this.url = `${proto}//${location.host}/api/ws/events`;
}
connect(): void {
try {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as AlertEvent;
if (data.type === 'alert') {
this.handlers.forEach((h) => h(data));
}
} catch {}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
} catch {
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 5000);
}
disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
subscribe(handler: EventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
}

View File

@@ -0,0 +1,29 @@
import type { AppConfig } from '@/types/config';
const BASE = '/api';
export async function fetchConfig(): Promise<AppConfig> {
const res = await fetch(`${BASE}/config`);
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`);
return res.json();
}
export async function saveConfig(config: AppConfig): Promise<void> {
const res = await fetch(`${BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error(`Failed to save config: ${res.status}`);
}
export async function discoverCameras(go2rtcUrl: string): Promise<string[]> {
try {
const res = await fetch(`${go2rtcUrl}/api/streams`);
if (!res.ok) return [];
const data = await res.json();
return Object.keys(data);
} catch {
return [];
}
}

View File

@@ -0,0 +1,137 @@
export class Go2RTCWebRTC {
private pc: RTCPeerConnection | null = null;
private mediaStream: MediaStream | null = null;
private streamName: string;
private go2rtcUrl: string;
private onTrackCb: ((stream: MediaStream) => void) | null = null;
constructor(streamName: string, go2rtcUrl: string) {
this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
}
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
this.onTrackCb = onTrack;
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
this.pc.ontrack = (event) => {
if (event.streams?.[0]) {
this.mediaStream = event.streams[0];
this.onTrackCb?.(this.mediaStream);
}
};
this.pc.onicecandidate = () => {
// go2rtc handles ICE internally via the initial SDP exchange
};
this.pc.addTransceiver('video', { direction: 'recvonly' });
this.pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
const url = `${this.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp,
});
if (!res.ok) {
throw new Error(`WebRTC offer failed: ${res.status}`);
}
const answerSdp = await res.text();
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
}
disconnect(): void {
this.mediaStream?.getTracks().forEach((t) => t.stop());
this.mediaStream = null;
this.pc?.close();
this.pc = null;
this.onTrackCb = null;
}
isConnected(): boolean {
return this.pc?.connectionState === 'connected';
}
}
export class Go2RTCMSE {
private mediaSource: MediaSource | null = null;
private sourceBuffer: SourceBuffer | null = null;
private ws: WebSocket | null = null;
private streamName: string;
private go2rtcUrl: string;
private videoElement: HTMLVideoElement | null = null;
private queue: ArrayBuffer[] = [];
constructor(streamName: string, go2rtcUrl: string) {
this.streamName = streamName;
this.go2rtcUrl = go2rtcUrl;
}
async connect(videoElement: HTMLVideoElement): Promise<void> {
this.videoElement = videoElement;
this.mediaSource = new MediaSource();
videoElement.src = URL.createObjectURL(this.mediaSource);
await new Promise<void>((resolve) => {
this.mediaSource!.addEventListener('sourceopen', () => resolve(), { once: true });
});
const wsUrl = `${this.go2rtcUrl.replace('http', 'ws')}/api/ws?src=${encodeURIComponent(this.streamName)}`;
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.type === 'mse' && msg.value) {
this.initSourceBuffer(msg.value);
}
} else if (this.sourceBuffer) {
if (this.sourceBuffer.updating) {
this.queue.push(event.data);
} else {
this.sourceBuffer.appendBuffer(event.data);
}
}
};
}
private initSourceBuffer(codec: string): void {
if (!this.mediaSource || this.sourceBuffer) return;
try {
this.sourceBuffer = this.mediaSource.addSourceBuffer(codec);
this.sourceBuffer.mode = 'segments';
this.sourceBuffer.addEventListener('updateend', () => {
if (this.queue.length > 0 && this.sourceBuffer && !this.sourceBuffer.updating) {
this.sourceBuffer.appendBuffer(this.queue.shift()!);
}
});
} catch (e) {
console.error(`Failed to create source buffer for ${this.streamName}:`, e);
}
}
disconnect(): void {
this.ws?.close();
this.ws = null;
if (this.mediaSource?.readyState === 'open') {
try { this.mediaSource.endOfStream(); } catch {}
}
if (this.videoElement) {
this.videoElement.src = '';
this.videoElement = null;
}
this.sourceBuffer = null;
this.mediaSource = null;
this.queue = [];
}
}