diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index a5313f1..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Lint and Build - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: self-hosted - - strategy: - matrix: - node-version: [ 20, 22 ] - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Clean NextJS cache - run: rm -rf .next - - - name: Install dependencies - run: npm ci - - - name: Run type check - run: npm run type-check - - - name: Run linter - run: npm run lint - - - name: Build project - run: npm run build - - - name: Upload Build Artifact - uses: actions/upload-artifact@v4 - with: - name: obs-ss-${{ matrix.node-version }} - include-hidden-files: 'true' - path: ./.next/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4a36acf..c841b0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,113 +15,68 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I - `npm run lint` - Run ESLint to check code quality - `npm run type-check` - Run TypeScript type checking without emitting files -### Database Management -- `npm run create-sat-summer-2025-tables` - Create database tables with seasonal naming convention - ## Architecture Overview ### Technology Stack -- **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and custom CSS with glass morphism design +- **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and Tailwind CSS - **Backend**: Next.js API routes - **Database**: SQLite with sqlite3 driver - **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio -- **Styling**: Custom CSS with Tailwind CSS utilities and modern glass card components +- **Styling**: Tailwind CSS with @tailwindcss/forms plugin ### Project Structure - `/app` - Next.js App Router pages and API routes - `/api` - Backend API endpoints for stream management - - `/add` - Streams management page (add new streams and view existing) - - `/teams` - Team management page - - `/edit/[id]` - Individual stream editing -- `/components` - Reusable React components (Header, Footer, Dropdown) + - `/add` - Page for adding new stream clients +- `/components` - Reusable React components (e.g., Dropdown) - `/lib` - Core utilities and database connection - `database.ts` - SQLite database initialization and connection management - - `obsClient.js` - OBS WebSocket client with persistent connection management - - `constants.ts` - Dynamic table naming system for seasonal deployments + - `obsClient.js` - OBS WebSocket client for communicating with OBS Studio + - `constants.ts` - Shared constants (table names, etc.) - `/types` - TypeScript type definitions - `/files` - Default directory for SQLite database and text files (configurable via .env.local) -- `/scripts` - Database setup and management scripts -- `/.forgejo/workflows` - Forgejo CI/CD workflows for self-hosted runners -### Key Architectural Concepts +### Key Concepts -1. **Dynamic Table Naming System**: Uses seasonal configuration for table names (e.g., `streams_2025_summer_sat`, `teams_2025_summer_sat`) to support recurring deployments +1. **Stream Management**: The app manages stream sources that can be assigned to different screen positions. Each stream has: + - name: Display name + - obs_source_name: Name of the source in OBS + - url: Stream URL + - team_id: Associated team identifier -2. **Persistent OBS Connection Management**: Single WebSocket connection shared across all API requests with automatic reconnection and connection state tracking +2. **Screen Types**: Seven different screen positions are supported: large, left, right, topLeft, topRight, bottomLeft, bottomRight -3. **Dual Integration Pattern**: - - WebSocket API for direct OBS control (source creation, status monitoring) - - Text file system for OBS Source Switcher plugin integration (source switching) +3. **Text File Integration**: The app writes the active source name to text files that OBS Source Switcher reads to switch sources automatically -4. **Glass Morphism UI Architecture**: Modern design system with backdrop blur effects, gradient backgrounds, and responsive glass card components - -5. **Screen Position Management**: Seven distinct screen positions (large, left, right, topLeft, topRight, bottomLeft, bottomRight) with individual source control - -6. **Real-time Status Monitoring**: Footer component polls OBS status every 30 seconds showing connection, streaming, and recording status - -### Environment Configuration -- `FILE_DIRECTORY`: Directory for database and text files (default: ./files) -- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) -- `OBS_WEBSOCKET_PORT`: OBS WebSocket port (default: 4455) -- `OBS_WEBSOCKET_PASSWORD`: OBS WebSocket password (optional) -- `API_KEY`: Required for API authentication (set in production) +4. **Environment Configuration**: + - `FILE_DIRECTORY`: Directory for database and text files (default: ./files) + - `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) + - `OBS_WEBSOCKET_PORT`: OBS WebSocket port (default: 4455) + - `OBS_WEBSOCKET_PASSWORD`: OBS WebSocket password (optional) ### API Endpoints -#### Stream Management -- `POST /api/addStream` - Add new stream to database and create browser source in OBS +- `POST /api/addStream` - Add new stream to database and OBS - `GET /api/streams` - Get all available streams -- `GET /api/streams/[id]` - Individual stream operations - -#### Source Control -- `POST /api/setActive` - Set active stream for specific screen position -- `GET /api/getActive` - Get currently active sources for all screens - -#### Team Management - `GET /api/teams` - Get all teams +- `GET /api/getActive` - Get currently active sources for all screens +- `POST /api/setActive` - Set active stream for a specific screen - `GET /api/getTeamName` - Get team name by ID -#### System Status -- `GET /api/obsStatus` - Real-time OBS connection and streaming status - ### Database Schema -Dynamic table names with seasonal configuration: -- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id -- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name +Two main tables: +- `streams`: id, name, obs_source_name, url, team_id +- `teams`: team_id, team_name ### OBS Integration Pattern -The app uses a sophisticated dual integration approach: +The app communicates with OBS through: +1. WebSocket connection using obs-websocket-js +2. Text files that OBS Source Switcher monitors for source changes +3. Direct source management through OBS WebSocket API -1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management -2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors - -**Source Control Workflow**: -1. User selects stream in React UI -2. API writes source name to position-specific text file (e.g., `large.txt`, `left.txt`) -3. OBS Source Switcher detects file change and switches to specified source -4. Real-time status updates via WebSocket API - -**Connection Management**: The OBS client ensures a single persistent connection across all API requests with automatic reconnection handling and connection state validation. - -### Component Patterns - -- **Client Components**: All interactive components use `'use client'` directive for React 19 compatibility -- **Optimistic Updates**: UI updates immediately with error rollback for responsive user experience -- **Consistent Layout**: Glass morphism design with unified component styling across all pages -- **Responsive Design**: Grid layouts adapt to different screen sizes with mobile-first approach - -### Security Architecture - -**Authentication**: API key-based authentication protects all API endpoints through Next.js middleware - -**Input Validation**: Comprehensive validation using centralized security utilities in `/lib/security.ts`: -- Screen parameter allowlisting prevents path traversal attacks -- URL validation ensures only http/https protocols -- String sanitization removes potentially dangerous characters -- Integer validation prevents injection attacks - -**Path Protection**: File operations are restricted to allowlisted screen names, preventing directory traversal - -**Error Handling**: Secure error responses that don't leak system information \ No newline at end of file +When setting an active source: +1. User selects stream in UI +2. API writes source name to corresponding text file (e.g., largeScreen.txt) +3. OBS Source Switcher detects file change and switches to that source \ No newline at end of file diff --git a/README.md b/README.md index 5169f28..7392ddd 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,28 @@ -# OBS Source Switcher Plugin UI - -A professional [Next.js](https://nextjs.org) web application for controlling multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) with real-time WebSocket integration and modern glass morphism UI. - -## Features - -- **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners) -- **Real-time OBS Integration**: WebSocket connection with live status monitoring -- **Team & Stream Management**: Organize streams by teams with full CRUD operations -- **Modern UI**: Glass morphism design with responsive layout -- **Professional Broadcasting**: Audio routing, scene management, and live status indicators -- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility - -## Quick Start - -```bash -npm install -npm run dev -``` - -Open [http://localhost:3000](http://localhost:3000) to access the control interface. +This is a [Next.js](https://nextjs.org) app to control multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) ## Configuration +The application uses a configurable directory for storing files and the database. To update the directory: create `.env.local` in the root of the project. -### Environment Variables - -Create `.env.local` in the project root: - -```env -# File storage directory (optional, defaults to ./files) +`.env.local` should look like: +``` FILE_DIRECTORY=C:\\OBS\\source-switching +``` +If no `.env.local` file is created, it will default to `./files`, as seen in `config.js` -# OBS WebSocket settings (optional, these are defaults) -OBS_WEBSOCKET_HOST=127.0.0.1 -OBS_WEBSOCKET_PORT=4455 -OBS_WEBSOCKET_PASSWORD=your_password_here -# Security (IMPORTANT: Set in production) -API_KEY=your_secure_api_key_here +```javascript + const config = { + FILE_DIRECTORY: path.resolve('./files'), + }; ``` -### Security Setup +In the "Source Switcher" properties in OBS, at the bottom, is a setting called `Current Source File`. Enable that, point it to the location of one of the text files, and put the read interval to 1000ms. Your source will change whenever the text file changes to a source _that is defined in the Source Switcher properties_ -**⚠️ IMPORTANT**: Set `API_KEY` in production to protect your OBS setup from unauthorized access. +The list of available sources is defined in a SQLite3 DB, location set in the `api/setActive.ts` route. -Generate a secure API key: -```bash -# Generate a random 32-character key -openssl rand -hex 32 -``` +`npm install` and +`npm run dev` to run it. -Without an API key, anyone on your network can control your OBS streams. - -### OBS Source Switcher Setup - -1. In OBS, configure Source Switcher properties -2. Enable "Current Source File" at the bottom -3. Point to one of the generated text files (e.g., `large.txt`, `left.txt`) -4. Set read interval to 1000ms -5. Sources will switch automatically when files change - -### Database Setup - -```bash -# Create seasonal database tables -npm run create-sat-summer-2025-tables -``` - -## Development Commands - -```bash -npm run dev # Start development server -npm run build # Build for production -npm run start # Start production server -npm run lint # Run ESLint -npm run type-check # TypeScript validation -``` - -## Architecture - -- **Frontend**: Next.js 15 with React 19 and TypeScript -- **Backend**: Next.js API routes with SQLite database -- **OBS Integration**: WebSocket connection + text file monitoring -- **Styling**: Custom CSS with glass morphism and Tailwind utilities -- **CI/CD**: Forgejo workflows with self-hosted runners - -## API Endpoints - -- `GET /api/streams` - List all streams -- `POST /api/addStream` - Create new stream and OBS source -- `POST /api/setActive` - Set active stream for screen position -- `GET /api/obsStatus` - Real-time OBS connection status -- `GET /api/teams` - Team management - -See `CLAUDE.md` for detailed architecture documentation. +This is my first [Next.js](https://nextjs.org) app and I am not a Javascript Developer professionally, use at your own risk. diff --git a/app/add/page.tsx b/app/add/page.tsx index 5d7025d..fd4d45b 100644 --- a/app/add/page.tsx +++ b/app/add/page.tsx @@ -19,7 +19,7 @@ export default function AddStream() { url: '', team_id: null, }); - const [teams, setTeams] = useState<{id: number; name: string}[]>([]); + const [teams, setTeams] = useState([]); const [streams, setStreams] = useState([]); const [isLoading, setIsLoading] = useState(true); const [message, setMessage] = useState(''); diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 940fcfe..48d2812 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -113,31 +113,15 @@ if (error instanceof Error) { } } -import { validateStreamInput } from '../../../lib/security'; - export async function POST(request: NextRequest) { - let name: string, obs_source_name: string, url: string, team_id: number; - - // Parse and validate request body try { const body = await request.json(); - const validation = validateStreamInput(body); + const { name, obs_source_name, url, team_id } = body; - if (!validation.valid) { - return NextResponse.json({ - error: 'Validation failed', - details: validation.errors - }, { status: 400 }); + if (!name || !obs_source_name || !url) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } - ({ name, obs_source_name, url, team_id } = validation.data!); - - } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); - } - - try { - // Connect to OBS WebSocket console.log("Pre-connect") await connectToOBS(); diff --git a/app/api/setActive/route.ts b/app/api/setActive/route.ts index a2e107a..a95b279 100644 --- a/app/api/setActive/route.ts +++ b/app/api/setActive/route.ts @@ -3,51 +3,41 @@ import fs from 'fs'; import path from 'path'; import { FILE_DIRECTORY } from '../../../config'; import { getDatabase } from '../../../lib/database'; -import { Stream } from '@/types'; -import { validateScreenInput } from '../../../lib/security'; +import { Stream, Screen } from '@/types'; export async function POST(request: NextRequest) { - // Parse and validate request body + const body: Screen = await request.json(); + const { screen, id } = body; + + const validScreens = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight']; + if (!validScreens.includes(screen)) { + return NextResponse.json({ error: 'Invalid screen name' }, { status: 400 }); + } + + console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`)); + const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`); + try { - const body = await request.json(); - const validation = validateScreenInput(body); + const db = await getDatabase(); + const stream: Stream | undefined = await db.get( + 'SELECT * FROM streams_2025_spring_adr WHERE id = ?', + [id] + ); - if (!validation.valid) { - return NextResponse.json({ - error: 'Validation failed', - details: validation.errors - }, { status: 400 }); + console.log('Stream:', stream); + + if (!stream) { + return NextResponse.json({ error: 'Stream not found' }, { status: 400 }); } - const { screen, id } = validation.data!; - - console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`)); - const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`); - - try { - const db = await getDatabase(); - const stream: Stream | undefined = await db.get( - 'SELECT * FROM streams_2025_spring_adr WHERE id = ?', - [id] - ); - - console.log('Stream:', stream); - - if (!stream) { - return NextResponse.json({ error: 'Stream not found' }, { status: 400 }); - } - - fs.writeFileSync(filePath, stream.obs_source_name); - return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 }); - } catch (error) { - console.error('Error updating active source:', error); - const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; - return NextResponse.json( - { error: 'Failed to update active source', details: errorMessage }, - { status: 500 } - ); - } - } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + fs.writeFileSync(filePath, stream.obs_source_name); + return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 }); +} catch (error) { +console.error('Error updating active source:', error); +const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; +return NextResponse.json( + { error: 'Failed to update active source', details: errorMessage }, + { status: 500 } +); } } diff --git a/lib/apiClient.ts b/lib/apiClient.ts deleted file mode 100644 index a388761..0000000 --- a/lib/apiClient.ts +++ /dev/null @@ -1,47 +0,0 @@ -// API client utility for making authenticated requests - -// Get API key from environment (client-side will need to be provided differently) -function getApiKey(): string | null { - if (typeof window === 'undefined') { - // Server-side - return process.env.API_KEY || null; - } else { - // Client-side - for now, return null to bypass auth in development - // In production, this would come from a secure storage or context - return null; - } -} - -// Authenticated fetch wrapper -export async function apiCall(url: string, options: RequestInit = {}): Promise { - const apiKey = getApiKey(); - - const headers: Record = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - // Add API key if available - if (apiKey) { - headers['x-api-key'] = apiKey; - } - - return fetch(url, { - ...options, - headers, - }); -} - -// Convenience methods -export const apiClient = { - get: (url: string) => apiCall(url, { method: 'GET' }), - post: (url: string, data: unknown) => apiCall(url, { - method: 'POST', - body: JSON.stringify(data) - }), - put: (url: string, data: unknown) => apiCall(url, { - method: 'PUT', - body: JSON.stringify(data) - }), - delete: (url: string) => apiCall(url, { method: 'DELETE' }), -}; \ No newline at end of file diff --git a/lib/security.ts b/lib/security.ts deleted file mode 100644 index e25f00d..0000000 --- a/lib/security.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Security utilities for input validation and sanitization - -export const VALID_SCREENS = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const; -export type ValidScreen = typeof VALID_SCREENS[number]; - -// Input validation functions -export function isValidScreen(screen: string): screen is ValidScreen { - return VALID_SCREENS.includes(screen as ValidScreen); -} - -export function isValidUrl(url: string): boolean { - try { - const urlObj = new URL(url); - return ['http:', 'https:'].includes(urlObj.protocol); - } catch { - return false; - } -} - -export function isPositiveInteger(value: unknown): value is number { - return Number.isInteger(value) && value > 0; -} - -// String sanitization -export function sanitizeString(input: string, maxLength: number = 100): string { - // Remove potentially dangerous characters and limit length - return input.replace(/[<>"/\\&]/g, '').trim().substring(0, maxLength); -} - -// Validation schemas -export interface StreamInput { - name: string; - obs_source_name: string; - url: string; - team_id: number; -} - -export interface ScreenInput { - screen: string; - id: number; -} - -export function validateStreamInput(input: unknown): { valid: boolean; errors: string[]; data?: StreamInput } { - const errors: string[] = []; - const data = input as Record; - - if (!data.name || typeof data.name !== 'string') { - errors.push('Name is required and must be a string'); - } else if (data.name.length > 100) { - errors.push('Name must be 100 characters or less'); - } - - if (!data.obs_source_name || typeof data.obs_source_name !== 'string') { - errors.push('OBS source name is required and must be a string'); - } else if (data.obs_source_name.length > 100) { - errors.push('OBS source name must be 100 characters or less'); - } - - if (!data.url || typeof data.url !== 'string') { - errors.push('URL is required and must be a string'); - } else if (!isValidUrl(data.url)) { - errors.push('URL must be a valid http:// or https:// URL'); - } - - if (!isPositiveInteger(data.team_id)) { - errors.push('Team ID must be a positive integer'); - } - - if (errors.length > 0) { - return { valid: false, errors }; - } - - return { - valid: true, - errors: [], - data: { - name: sanitizeString(data.name as string), - obs_source_name: sanitizeString(data.obs_source_name as string), - url: data.url as string, - team_id: data.team_id as number, - }, - }; -} - -export function validateScreenInput(input: unknown): { valid: boolean; errors: string[]; data?: ScreenInput } { - const errors: string[] = []; - const data = input as Record; - - if (!data.screen || typeof data.screen !== 'string') { - errors.push('Screen is required and must be a string'); - } else if (!isValidScreen(data.screen)) { - errors.push(`Screen must be one of: ${VALID_SCREENS.join(', ')}`); - } - - if (!isPositiveInteger(data.id)) { - errors.push('ID must be a positive integer'); - } - - if (errors.length > 0) { - return { valid: false, errors }; - } - - return { - valid: true, - errors: [], - data: { - screen: data.screen as string, - id: data.id as number, - }, - }; -} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index 910dd00..0000000 --- a/middleware.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export function middleware(request: NextRequest) { - // Only protect API routes - if (request.nextUrl.pathname.startsWith('/api/')) { - // Allow OPTIONS requests for CORS preflight - if (request.method === 'OPTIONS') { - return NextResponse.next(); - } - - // Check for API key in header - const apiKey = request.headers.get('x-api-key'); - const validKey = process.env.API_KEY; - - // If API_KEY is not set in environment, skip authentication (development mode) - if (!validKey) { - console.warn('API_KEY not set in environment variables. API endpoints are unprotected!'); - return NextResponse.next(); - } - - // Skip authentication for localhost/internal requests (optional security) - const host = request.headers.get('host'); - if (host && (host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('192.168.'))) { - console.log('Allowing internal network access without API key'); - return NextResponse.next(); - } - - // Validate API key for external requests - if (!apiKey || apiKey !== validKey) { - return NextResponse.json( - { error: 'Unauthorized. Valid API key required.' }, - { status: 401 } - ); - } - } - - return NextResponse.next(); -} - -export const config = { - matcher: '/api/:path*' -}; \ No newline at end of file