diff --git a/CLAUDE.md b/CLAUDE.md index 692e8f6..b1cbf06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,6 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I ### Database Management - `npm run create-sat-summer-2025-tables` - Create database tables with seasonal naming convention -- `npm run migrate-add-group-uuid` - Add group_uuid column to existing teams table (one-time migration) ## Architecture Overview @@ -34,13 +33,11 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I - `/streams` - 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, Toast) +- `/components` - Reusable React components (Header, Footer, 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 - - `useToast.ts` - Toast notification system for user feedback - - `security.ts` - Input validation and sanitization utilities - `/types` - TypeScript type definitions - `/files` - Default directory for SQLite database and text files (configurable via .env.local) - `/scripts` - Database setup and management scripts @@ -66,12 +63,6 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I 6. **Real-time Status Monitoring**: Footer component polls OBS status every 30 seconds showing connection, streaming, and recording status -7. **UUID-based OBS Group Tracking**: Robust synchronization between database teams and OBS scenes using UUID identifiers to handle manual renames and ensure data consistency - -8. **Toast Notification System**: User-friendly feedback system with success, error, and informational messages for all operations - -9. **Stream Deletion with Confirmation**: Safe deletion workflow that removes streams from both OBS and database with user confirmation prompts - ### Environment Configuration - `FILE_DIRECTORY`: Directory for database and text files (default: ./files) - `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) @@ -82,24 +73,19 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I ### API Endpoints #### Stream Management -- `POST /api/addStream` - Add new stream to database and create browser source in OBS (accepts Twitch username, auto-generates URL) +- `POST /api/addStream` - Add new stream to database and create browser source in OBS - `GET /api/streams` - Get all available streams -- `GET /api/streams/[id]` - Get individual stream details -- `DELETE /api/streams/[id]` - Delete stream from both OBS and database with confirmation +- `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 with group information -- `POST /api/teams` - Create new team -- `PUT /api/teams/[id]` - Update team name, group_name, or group_uuid -- `DELETE /api/teams/[id]` - Delete team and associated streams +- `GET /api/teams` - Get all teams - `GET /api/getTeamName` - Get team name by ID -- `POST /api/createGroup` - Create OBS group from team and store UUID +- `POST /api/createGroup` - Create OBS group from team - `POST /api/syncGroups` - Synchronize all teams with OBS groups -- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking #### System Status - `GET /api/obsStatus` - Real-time OBS connection and streaming status @@ -108,13 +94,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I 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, group_name, group_uuid - -**Database Fields**: -- `streams` table: Stores stream information with team associations -- `teams` table: Stores team information with optional OBS group mapping - - `group_name`: Human-readable OBS scene name - - `group_uuid`: OBS scene UUID for reliable tracking (handles renames) +- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name ### OBS Integration Pattern @@ -122,11 +102,7 @@ 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 -3. **UUID-based Group Management**: Teams mapped to OBS scenes with UUID tracking for reliable synchronization - - Primary matching by UUID for rename-safe tracking - - Fallback to name matching for backward compatibility - - Automatic detection of name changes and sync issues - - UI actions for resolving synchronization problems +3. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management **Required OBS Source Switchers** (must be created with these exact names): - `ss_large` - Large screen source switcher @@ -147,26 +123,12 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio **Connection Management**: The OBS client ensures a single persistent connection across all API requests with automatic reconnection handling and connection state validation. -**Group Synchronization Workflow**: -1. Team creation optionally creates corresponding OBS scene -2. UUID stored in database for reliable tracking -3. Verification system detects sync issues (missing groups, name changes) -4. UI provides actions to fix sync problems: - - "Clear Invalid" - Remove broken group assignments - - "Update Name" - Sync database with OBS name changes - - Visual indicators show sync status and UUID linking - ### 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 -- **Toast Notifications**: Comprehensive feedback system with success/error messages for all operations -- **Confirmation Dialogs**: Safe deletion workflows with user confirmation prompts -- **Real-time Validation**: Client-side form validation with immediate feedback -- **Dropdown Components**: Portal-based dropdowns with proper z-index handling and scroll-aware positioning - **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 -- **Accessibility**: High contrast ratios, keyboard navigation, and screen reader support ### Security Architecture @@ -180,36 +142,4 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio **Path Protection**: File operations are restricted to allowlisted screen names, preventing directory traversal -**Error Handling**: Secure error responses that don't leak system information - -## Key Features & Recent Enhancements - -### Stream Management -- **Twitch Integration**: Simplified stream addition using just Twitch username (auto-generates full URL) -- **Stream Deletion**: Safe deletion workflow with confirmation that removes from both OBS and database -- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility -- **Team Association**: Streams can be organized under teams for better management - -### Team & Group Management -- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs -- **Sync Verification**: Real-time verification of database-OBS group synchronization -- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes) -- **Visual Indicators**: Clear status indicators for group linking and sync problems - - 🆔 "Linked by UUID" - Group tracked by reliable UUID - - 📝 "Name changed in OBS" - Group renamed in OBS, database needs update - - ⚠️ "Not found in OBS" - Group in database but missing from OBS - -### User Experience Improvements -- **Toast Notifications**: Real-time feedback for all operations (success/error/info) -- **Form Validation**: Client-side validation with immediate error feedback -- **Confirmation Prompts**: Safe deletion workflows prevent accidental data loss -- **Responsive Design**: Mobile-friendly interface with glass morphism styling -- **Loading States**: Clear indicators during API operations -- **Error Recovery**: Graceful error handling with user-friendly messages - -### Developer Experience -- **Type Safety**: Comprehensive TypeScript definitions throughout -- **API Documentation**: Well-documented endpoints with clear parameter validation -- **Migration Scripts**: Database migration tools for schema updates -- **Security**: Input validation, sanitization, and secure API design -- **Testing**: Comprehensive error handling and edge case management \ No newline at end of file +**Error Handling**: Secure error responses that don't leak system information \ No newline at end of file diff --git a/app/api/createGroup/route.ts b/app/api/createGroup/route.ts index af86197..10c3c44 100644 --- a/app/api/createGroup/route.ts +++ b/app/api/createGroup/route.ts @@ -43,15 +43,15 @@ export async function POST(request: NextRequest) { suffix: 'sat' }); - // Create group in OBS first to get UUID - const result = await createGroupIfNotExists(sanitizedGroupName); - - // Update team with group name and UUID + // Update team with group name await db.run( - `UPDATE ${teamsTableName} SET group_name = ?, group_uuid = ? WHERE team_id = ?`, - [sanitizedGroupName, result.sceneUuid, validTeamId] + `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({ diff --git a/app/api/teams/[teamId]/route.ts b/app/api/teams/[teamId]/route.ts index 74582f4..a132725 100644 --- a/app/api/teams/[teamId]/route.ts +++ b/app/api/teams/[teamId]/route.ts @@ -9,40 +9,17 @@ export async function PUT( try { const { teamId: teamIdParam } = await params; const teamId = parseInt(teamIdParam); - const body = await request.json(); - const { team_name, group_name, group_uuid } = body; + const { team_name } = await request.json(); - // Allow updating any combination of fields - if (!team_name && group_name === undefined && group_uuid === undefined) { - return NextResponse.json({ error: 'At least one field (team_name, group_name, or group_uuid) must be provided' }, { status: 400 }); + if (!team_name) { + return NextResponse.json({ error: 'Team name is required' }, { status: 400 }); } const db = await getDatabase(); - // Build dynamic query based on what fields are being updated - const updates: string[] = []; - const values: (string | number | null)[] = []; - - if (team_name) { - updates.push('team_name = ?'); - values.push(team_name); - } - - if (group_name !== undefined) { - updates.push('group_name = ?'); - values.push(group_name); - } - - if (group_uuid !== undefined) { - updates.push('group_uuid = ?'); - values.push(group_uuid); - } - - values.push(teamId); - const result = await db.run( - `UPDATE ${TABLE_NAMES.TEAMS} SET ${updates.join(', ')} WHERE team_id = ?`, - values + `UPDATE ${TABLE_NAMES.TEAMS} SET team_name = ? WHERE team_id = ?`, + [team_name, teamId] ); if (result.changes === 0) { diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 2ccdaf0..36addd1 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -45,7 +45,7 @@ function validateTeamInput(data: unknown): { export const GET = withErrorHandling(async () => { try { const db = await getDatabase(); - const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name, group_uuid 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,8 +86,7 @@ export const POST = withErrorHandling(async (request: Request) => { const newTeam: Team = { team_id: result.lastID!, team_name: team_name, - group_name: null, - group_uuid: null + group_name: null }; return createSuccessResponse(newTeam, 201); diff --git a/app/api/verifyGroups/route.ts b/app/api/verifyGroups/route.ts deleted file mode 100644 index df210fb..0000000 --- a/app/api/verifyGroups/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getDatabase } from '../../../lib/database'; -import { TABLE_NAMES } from '../../../lib/constants'; -import { getOBSClient } from '../../../lib/obsClient'; - -interface OBSScene { - sceneName: string; - sceneUuid: string; -} - -interface GetSceneListResponse { - scenes: OBSScene[]; -} - -export async function GET() { - try { - // Get teams from database - const db = await getDatabase(); - const teams = await db.all(`SELECT team_id, team_name, group_name, group_uuid FROM ${TABLE_NAMES.TEAMS} WHERE group_name IS NOT NULL OR group_uuid IS NOT NULL`); - - // Get scenes (groups) from OBS - const obs = await getOBSClient(); - const response = await obs.call('GetSceneList'); - const obsData = response as GetSceneListResponse; - const obsScenes = obsData.scenes; - - // Compare database groups with OBS scenes using both UUID and name - const verification = teams.map(team => { - let exists_in_obs = false; - let matched_by = null; - let current_name = null; - - if (team.group_uuid) { - // Try to match by UUID first (most reliable) - const matchedScene = obsScenes.find(scene => scene.sceneUuid === team.group_uuid); - if (matchedScene) { - exists_in_obs = true; - matched_by = 'uuid'; - current_name = matchedScene.sceneName; - } - } - - if (!exists_in_obs && team.group_name) { - // Fallback to name matching - const matchedScene = obsScenes.find(scene => scene.sceneName === team.group_name); - if (matchedScene) { - exists_in_obs = true; - matched_by = 'name'; - current_name = matchedScene.sceneName; - } - } - - return { - team_id: team.team_id, - team_name: team.team_name, - group_name: team.group_name, - group_uuid: team.group_uuid, - exists_in_obs, - matched_by, - current_name, - name_changed: exists_in_obs && matched_by === 'uuid' && current_name !== team.group_name - }; - }); - - return NextResponse.json({ - success: true, - data: { - teams_with_groups: verification, - obs_scenes: obsScenes.map(s => ({ name: s.sceneName, uuid: s.sceneUuid })), - missing_in_obs: verification.filter(team => !team.exists_in_obs), - name_mismatches: verification.filter(team => team.name_changed), - orphaned_in_obs: obsScenes.filter(scene => - !teams.some(team => team.group_uuid === scene.sceneUuid || team.group_name === scene.sceneName) - ).map(s => ({ name: s.sceneName, uuid: s.sceneUuid })) - } - }); - - } catch (error) { - console.error('Error verifying groups:', error); - return NextResponse.json( - { error: 'Failed to verify groups with OBS' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/teams/page.tsx b/app/teams/page.tsx index 7b877e4..bbbc38a 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -5,22 +5,9 @@ import { Team } from '@/types'; import { useToast } from '@/lib/useToast'; import { ToastContainer } from '@/components/Toast'; -interface GroupVerification { - team_id: number; - team_name: string; - group_name: string; - group_uuid: string | null; - exists_in_obs: boolean; - matched_by: 'uuid' | 'name' | null; - current_name: string | null; - name_changed: boolean; -} - export default function Teams() { const [teams, setTeams] = useState([]); - const [groupVerification, setGroupVerification] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [isVerifying, setIsVerifying] = useState(false); const [newTeamName, setNewTeamName] = useState(''); const [editingTeam, setEditingTeam] = useState(null); const [editingName, setEditingName] = useState(''); @@ -51,35 +38,6 @@ export default function Teams() { } }; - const verifyGroups = async () => { - setIsVerifying(true); - try { - const res = await fetch('/api/verifyGroups'); - const data = await res.json(); - if (data.success) { - setGroupVerification(data.data.teams_with_groups); - const missing = data.data.missing_in_obs.length; - const orphaned = data.data.orphaned_in_obs.length; - const nameChanges = data.data.name_mismatches?.length || 0; - - if (missing > 0 || orphaned > 0 || nameChanges > 0) { - const issues = []; - if (missing > 0) issues.push(`${missing} missing in OBS`); - if (orphaned > 0) issues.push(`${orphaned} orphaned in OBS`); - if (nameChanges > 0) issues.push(`${nameChanges} name mismatches`); - showError('Groups Out of Sync', issues.join(', ')); - } else { - showSuccess('Groups Verified', 'All groups are in sync with OBS'); - } - } - } catch (error) { - console.error('Error verifying groups:', error); - showError('Verification Failed', 'Could not verify groups with OBS'); - } finally { - setIsVerifying(false); - } - }; - const handleAddTeam = async (e: React.FormEvent) => { e.preventDefault(); @@ -235,7 +193,6 @@ export default function Teams() { if (res.ok) { fetchTeams(); - verifyGroups(); // Refresh verification after creating showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`); } else { const error = await res.json(); @@ -249,58 +206,6 @@ export default function Teams() { } }; - const handleClearInvalidGroup = async (teamId: number, teamName: string) => { - if (!confirm(`Clear the invalid group assignment for team "${teamName}"? This will only update the database, not delete anything from OBS.`)) { - return; - } - - try { - const res = await fetch(`/api/teams/${teamId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: null, group_uuid: null }), - }); - - if (res.ok) { - fetchTeams(); - verifyGroups(); - showSuccess('Group Cleared', `Invalid group assignment cleared for "${teamName}"`); - } else { - const error = await res.json(); - showError('Failed to Clear Group', error.error || 'Unknown error occurred'); - } - } catch (error) { - console.error('Error clearing group:', error); - showError('Failed to Clear Group', 'Network error or server unavailable'); - } - }; - - const handleUpdateGroupName = async (teamId: number, teamName: string, currentName: string) => { - if (!confirm(`Update the group name for team "${teamName}" from "${teamName}" to "${currentName}" to match OBS?`)) { - return; - } - - try { - const res = await fetch(`/api/teams/${teamId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: currentName }), - }); - - if (res.ok) { - fetchTeams(); - verifyGroups(); - showSuccess('Group Name Updated', `Group name updated to "${currentName}"`); - } else { - const error = await res.json(); - showError('Failed to Update Group Name', error.error || 'Unknown error occurred'); - } - } catch (error) { - console.error('Error updating group name:', error); - showError('Failed to Update Group Name', 'Network error or server unavailable'); - } - }; - const startEditing = (team: Team) => { setEditingTeam(team); setEditingName(team.team_name); @@ -366,26 +271,15 @@ export default function Teams() {

Existing Teams

-
- - -
+
{isLoading ? ( @@ -403,10 +297,7 @@ export default function Teams() {
) : (
- {teams.map((team) => { - const shouldShowCreateButton = !team.group_name || (typeof team.group_name === 'string' && team.group_name.trim() === ''); - const verification = groupVerification.find(v => v.team_id === team.team_id); - return ( + {teams.map((team) => (
{editingTeam?.team_id === team.team_id ? (
@@ -447,27 +338,14 @@ export default function Teams() {
{team.team_name}
ID: {team.team_id}
{team.group_name ? ( -
- - OBS Group: {verification?.current_name || team.group_name} - - {verification && !verification.exists_in_obs && ( - ⚠️ Not found in OBS - )} - {verification && verification.name_changed && ( - 📝 Name changed in OBS - )} - {verification?.matched_by === 'uuid' && ( - 🆔 Linked by UUID - )} -
+
OBS Group: {team.group_name}
) : (
No OBS Group
)}
- {shouldShowCreateButton && ( + {!team.group_name && ( )} - {verification && !verification.exists_in_obs && ( - - )} - {verification && verification.name_changed && verification.current_name && ( - - )}
)}
- ); - })} + ))} )} diff --git a/lib/apiHelpers.ts b/lib/apiHelpers.ts index 7373f17..d6c5687 100644 --- a/lib/apiHelpers.ts +++ b/lib/apiHelpers.ts @@ -140,7 +140,7 @@ export async function parseRequestBody( } return { success: true, data: body as T }; - } catch { + } catch (_error) { return { success: false, response: createErrorResponse( diff --git a/lib/obsClient.js b/lib/obsClient.js index 4d323b3..5465302 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -124,30 +124,17 @@ async function createGroupIfNotExists(groupName) { try { const obsClient = await getOBSClient(); - // Check if the group (scene) exists and get its UUID + // Check if the group (scene) exists const { scenes } = await obsClient.call('GetSceneList'); - const existingScene = scenes.find((scene) => scene.sceneName === groupName); + const groupExists = scenes.some((scene) => scene.sceneName === groupName); - if (!existingScene) { + if (!groupExists) { console.log(`Creating group "${groupName}"`); await obsClient.call('CreateScene', { sceneName: groupName }); - - // Get the scene UUID after creation - const { scenes: updatedScenes } = await obsClient.call('GetSceneList'); - const newScene = updatedScenes.find((scene) => scene.sceneName === groupName); - - return { - created: true, - message: `Group "${groupName}" created successfully`, - sceneUuid: newScene?.sceneUuid || null - }; + return { created: true, message: `Group "${groupName}" created successfully` }; } else { console.log(`Group "${groupName}" already exists`); - return { - created: false, - message: `Group "${groupName}" already exists`, - sceneUuid: existingScene.sceneUuid - }; + return { created: false, message: `Group "${groupName}" already exists` }; } } catch (error) { console.error('Error creating group:', error.message); diff --git a/lib/performance.ts b/lib/performance.ts index fa8a079..c523293 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -193,6 +193,5 @@ export function useSmartPolling( intervalRef.current = null; } }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [interval, isVisible, ...dependencies]); } \ No newline at end of file diff --git a/package.json b/package.json index 7af2f60..0102daa 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --coverage --watchAll=false", - "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts", - "add-group-uuid-column": "tsx scripts/addGroupUuidColumn.ts" + "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts" }, "dependencies": { "@tailwindcss/postcss": "^4.1.11", diff --git a/scripts/addGroupUuidColumn.ts b/scripts/addGroupUuidColumn.ts deleted file mode 100644 index 949af9e..0000000 --- a/scripts/addGroupUuidColumn.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { open } from 'sqlite'; -import sqlite3 from 'sqlite3'; -import path from 'path'; -import { getTableName, BASE_TABLE_NAMES } from '../lib/constants'; - -async function addGroupUuidColumn() { - const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); - const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); - - try { - const db = await open({ - filename: dbPath, - driver: sqlite3.Database, - }); - - const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { - year: 2025, - season: 'summer', - suffix: 'sat' - }); - - // Check if column already exists - const columns = await db.all(`PRAGMA table_info(${teamsTableName})`); - const hasGroupUuid = columns.some((col: any) => col.name === 'group_uuid'); - - if (hasGroupUuid) { - console.log('group_uuid column already exists'); - await db.close(); - return; - } - - // Add the new column - await db.run(`ALTER TABLE ${teamsTableName} ADD COLUMN group_uuid TEXT NULL`); - - console.log('Successfully added group_uuid column to teams table'); - - await db.close(); - } catch (error) { - console.error('Error adding group_uuid column:', error); - process.exit(1); - } -} - -// Run the migration -addGroupUuidColumn().then(() => { - console.log('Migration completed'); - process.exit(0); -}); \ No newline at end of file diff --git a/types/index.ts b/types/index.ts index 8933587..fcb17f1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -15,5 +15,4 @@ export type Team = { team_id: number; team_name: string; group_name?: string | null; - group_uuid?: string | null; }; \ No newline at end of file