From 5789986bb62be9958be7bb6dd36e4925e3144454 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sun, 20 Jul 2025 00:28:16 -0400 Subject: [PATCH] Add OBS group management feature and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add group_name column to teams table for mapping teams to OBS groups - Create API endpoints for group creation (/api/createGroup) and bulk sync (/api/syncGroups) - Update teams UI with group status display and creation buttons - Implement automatic group assignment when adding streams - Add comprehensive OBS setup documentation (docs/OBS_SETUP.md) - Fix team list spacing issue with explicit margins - Update OBS client with group management functions - Add database migration script for existing deployments 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 11 +++ app/api/addStream/route.ts | 140 +++++++++------------------ app/api/createGroup/route.ts | 67 +++++++++++++ app/api/syncGroups/route.ts | 81 ++++++++++++++++ app/api/teams/route.ts | 8 +- app/teams/page.tsx | 91 ++++++++++++++++- docs/OBS_SETUP.md | 99 +++++++++++++++++++ lib/apiClient.ts | 2 +- lib/obsClient.js | 110 +++++++++++++-------- lib/performance.ts | 4 +- lib/security.ts | 10 +- scripts/addGroupNameToTeams.ts | 57 +++++++++++ scripts/createSatSummer2025Tables.ts | 3 +- types/index.ts | 1 + 14 files changed, 540 insertions(+), 144 deletions(-) create mode 100644 app/api/createGroup/route.ts create mode 100644 app/api/syncGroups/route.ts create mode 100644 docs/OBS_SETUP.md create mode 100644 scripts/addGroupNameToTeams.ts diff --git a/CLAUDE.md b/CLAUDE.md index e9b1a8b..6c7dea3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,17 @@ The app uses a sophisticated dual integration approach: 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 +**Required OBS Source Switchers** (must be created with these exact names): +- `ss_large` - Large screen source switcher +- `ss_left` - Left screen source switcher +- `ss_right` - Right screen source switcher +- `ss_top_left` - Top left screen source switcher +- `ss_top_right` - Top right screen source switcher +- `ss_bottom_left` - Bottom left screen source switcher +- `ss_bottom_right` - Bottom right screen source switcher + +See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructions. + **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`) diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 940fcfe..750962e 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -1,24 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; -import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher } from '../../../lib/obsClient'; +import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup } from '../../../lib/obsClient'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '../../../lib/constants'; interface OBSClient { call: (method: string, params?: Record) => Promise>; } -interface OBSScene { -sceneName: string; -} - interface OBSInput { inputName: string; } -interface GetSceneListResponse { -currentProgramSceneName: string; -currentPreviewSceneName: string; -scenes: OBSScene[]; -} interface GetInputListResponse { inputs: OBSInput[]; @@ -33,85 +28,38 @@ const screens = [ 'ss_bottom_right', ]; -async function fetchTeamName(teamId: number) { +async function fetchTeamInfo(teamId: number) { + const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); try { - const baseUrl = process.env.BASE_URL || 'http://localhost:3000'; - const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`); - if (!response.ok) { - throw new Error('Failed to fetch team name'); - } - const data = await response.json(); - return data.team_name; -} catch (error) { -if (error instanceof Error) { - console.error('Error:', error.message); -} else { - console.error('An unknown error occurred:', error); -} -return null; -} -} - -async function addBrowserSourceWithAudioControl(obs: OBSClient, sceneName: string, inputName: string, url: string) { - try { - // Step 1: Create the browser source input - await obs.call('CreateInput', { - sceneName, - inputName, - inputKind: 'browser_source', - inputSettings: { - width: 1600, - height: 900, - url, - }, + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, }); - console.log(`Browser source "${inputName}" created successfully.`); - - // Step 2: Wait for the input to initialize - let inputReady = false; - for (let i = 0; i < 10; i++) { - try { - await obs.call('GetInputSettings', { inputName }); - inputReady = true; - break; - } catch { - console.log(`Waiting for input "${inputName}" to initialize...`); - await new Promise((resolve) => setTimeout(resolve, 500)); // Wait 500ms before retrying - } - } - - if (!inputReady) { - throw new Error(`Input "${inputName}" did not initialize in time.`); - } - - - // Step 3: Enable "Reroute audio" - await obs.call('SetInputSettings', { - inputName, - inputSettings: { - reroute_audio: true, - }, - overlay: true, // Keep existing settings and apply changes + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' }); - console.log(`Audio rerouted for "${inputName}".`); + const teamInfo = await db.get( + `SELECT team_name, group_name FROM ${teamsTableName} WHERE team_id = ?`, + [teamId] + ); - // Step 4: Mute the input - await obs.call('SetInputMute', { - inputName, - inputMuted: true, - }); + await db.close(); + return teamInfo; + } catch (error) { + if (error instanceof Error) { + console.error('Error fetching team info:', error.message); + } else { + console.error('An unknown error occurred:', error); + } + return null; + } +} - console.log(`Audio muted for "${inputName}".`); -} catch (error) { -if (error instanceof Error) { - console.error('Error adding browser source with audio control:', error.message); -} else { - console.error('An unknown error occurred while adding browser source:', error); -} -} -} import { validateStreamInput } from '../../../lib/security'; @@ -160,20 +108,23 @@ export async function POST(request: NextRequest) { } throw new Error('GetInputList failed.'); } - const teamName = await fetchTeamName(team_id); - console.log('Team Name:', teamName) - const response = await obs.call('GetSceneList'); - const sceneListResponse = response as unknown as GetSceneListResponse; - const { scenes } = sceneListResponse; - const groupExists = scenes.some((scene: OBSScene) => scene.sceneName === teamName); - if (!groupExists) { - await obs.call('CreateScene', { sceneName: teamName }); + + const teamInfo = await fetchTeamInfo(team_id); + if (!teamInfo) { + throw new Error('Team not found'); } + console.log('Team Info:', teamInfo); + + // Use group_name if it exists, otherwise use team_name + const groupName = teamInfo.group_name || teamInfo.team_name; + const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name); if (!sourceExists) { - await addBrowserSourceWithAudioControl(obs, teamName, obs_source_name, url) + // Create/ensure group exists and add source to it + await createGroupIfNotExists(groupName); + await addSourceToGroup(groupName, obs_source_name, url); console.log(`OBS source "${obs_source_name}" created.`); @@ -196,7 +147,12 @@ export async function POST(request: NextRequest) { } const db = await getDatabase(); - const query = `INSERT INTO streams_2025_spring_adr (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`; + const streamsTableName = getTableName(BASE_TABLE_NAMES.STREAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + const query = `INSERT INTO ${streamsTableName} (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`; db.run(query, [name, obs_source_name, url, team_id]) await disconnectFromOBS(); return NextResponse.json({ message: 'Stream added successfully' }, {status: 201}) diff --git a/app/api/createGroup/route.ts b/app/api/createGroup/route.ts new file mode 100644 index 0000000..10c3c44 --- /dev/null +++ b/app/api/createGroup/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants'; +import { validateInteger } from '@/lib/security'; + +const { createGroupIfNotExists } = require('@/lib/obsClient'); + +const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { teamId, groupName } = body; + + // Validate input + if (!teamId || !groupName) { + return NextResponse.json({ error: 'Team ID and group name are required' }, { status: 400 }); + } + + const validTeamId = validateInteger(teamId); + if (!validTeamId) { + return NextResponse.json({ error: 'Invalid team ID' }, { status: 400 }); + } + + // Sanitize group name (only allow alphanumeric, spaces, dashes, underscores) + const sanitizedGroupName = groupName.replace(/[^a-zA-Z0-9\s\-_]/g, ''); + if (!sanitizedGroupName || sanitizedGroupName.length === 0) { + return NextResponse.json({ error: 'Invalid group name' }, { status: 400 }); + } + + // Open database connection + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + // Update team with group name + await db.run( + `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, + [sanitizedGroupName, validTeamId] + ); + + // Create group in OBS + const result = await createGroupIfNotExists(sanitizedGroupName); + + await db.close(); + + return NextResponse.json({ + success: true, + message: 'Group created/updated successfully', + groupName: sanitizedGroupName, + obsResult: result + }); + } catch (error) { + console.error('Error creating group:', error); + return NextResponse.json({ error: 'Failed to create group' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/syncGroups/route.ts b/app/api/syncGroups/route.ts new file mode 100644 index 0000000..7e0a5c4 --- /dev/null +++ b/app/api/syncGroups/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants'; + +const { createGroupIfNotExists } = require('@/lib/obsClient'); + +const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + +export async function POST() { + try { + // Open database connection + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + // Get all teams without groups + const teamsWithoutGroups = await db.all( + `SELECT team_id, team_name FROM ${teamsTableName} WHERE group_name IS NULL` + ); + + const syncResults = []; + + for (const team of teamsWithoutGroups) { + try { + // Create group in OBS using team name + const obsResult = await createGroupIfNotExists(team.team_name); + + // Update database with group name + await db.run( + `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, + [team.team_name, team.team_id] + ); + + syncResults.push({ + teamId: team.team_id, + teamName: team.team_name, + groupName: team.team_name, + success: true, + obsResult + }); + } catch (error) { + console.error(`Error syncing team ${team.team_id}:`, error); + syncResults.push({ + teamId: team.team_id, + teamName: team.team_name, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + await db.close(); + + const successCount = syncResults.filter(r => r.success).length; + const failureCount = syncResults.filter(r => !r.success).length; + + return NextResponse.json({ + success: true, + message: `Sync completed: ${successCount} successful, ${failureCount} failed`, + results: syncResults, + summary: { + total: syncResults.length, + successful: successCount, + failed: failureCount + } + }); + } catch (error) { + console.error('Error syncing groups:', error); + return NextResponse.json({ error: 'Failed to sync groups' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index ce99d57..36addd1 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -1,4 +1,3 @@ -import { NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; import { Team } from '@/types'; import { TABLE_NAMES } from '@/lib/constants'; @@ -39,14 +38,14 @@ function validateTeamInput(data: unknown): { return { valid: true, - data: { team_name: team_name.trim() } + data: { team_name: (team_name as string).trim() } }; } export const GET = withErrorHandling(async () => { try { const db = await getDatabase(); - const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); + const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); return createSuccessResponse(teams); } catch (error) { @@ -86,7 +85,8 @@ export const POST = withErrorHandling(async (request: Request) => { const newTeam: Team = { team_id: result.lastID!, - team_name: team_name + team_name: team_name, + group_name: null }; return createSuccessResponse(newTeam, 201); diff --git a/app/teams/page.tsx b/app/teams/page.tsx index 223e73a..bbbc38a 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -11,6 +11,8 @@ export default function Teams() { const [newTeamName, setNewTeamName] = useState(''); const [editingTeam, setEditingTeam] = useState(null); const [editingName, setEditingName] = useState(''); + const [creatingGroupForTeam, setCreatingGroupForTeam] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [updatingTeamId, setUpdatingTeamId] = useState(null); const [deletingTeamId, setDeletingTeamId] = useState(null); @@ -146,6 +148,64 @@ export default function Teams() { } }; + const handleSyncAllGroups = async () => { + if (!confirm('This will create OBS groups for all teams that don\'t have one. Continue?')) { + return; + } + + setIsSyncing(true); + try { + const res = await fetch('/api/syncGroups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (res.ok) { + const result = await res.json(); + fetchTeams(); + showSuccess('Groups Synced', `${result.summary.successful} groups created successfully`); + if (result.summary.failed > 0) { + showError('Some Failures', `${result.summary.failed} groups failed to create`); + } + } else { + const error = await res.json(); + showError('Failed to Sync Groups', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error syncing groups:', error); + showError('Failed to Sync Groups', 'Network error or server unavailable'); + } finally { + setIsSyncing(false); + } + }; + + const handleCreateGroup = async (teamId: number, teamName: string) => { + const groupName = prompt(`Enter group name for team "${teamName}":`, teamName); + if (!groupName) return; + + setCreatingGroupForTeam(teamId); + try { + const res = await fetch('/api/createGroup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ teamId, groupName }), + }); + + if (res.ok) { + fetchTeams(); + showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`); + } else { + const error = await res.json(); + showError('Failed to Create Group', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error creating group:', error); + showError('Failed to Create Group', 'Network error or server unavailable'); + } finally { + setCreatingGroupForTeam(null); + } + }; + const startEditing = (team: Team) => { setEditingTeam(team); setEditingName(team.team_name); @@ -209,7 +269,18 @@ export default function Teams() { {/* Teams List */}
-

Existing Teams

+
+

Existing Teams

+ +
{isLoading ? (
@@ -227,7 +298,7 @@ export default function Teams() { ) : (
{teams.map((team) => ( -
+
{editingTeam?.team_id === team.team_id ? (
{team.team_name}
ID: {team.team_id}
+ {team.group_name ? ( +
OBS Group: {team.group_name}
+ ) : ( +
No OBS Group
+ )}
+ {!team.group_name && ( + + )}