diff --git a/CLAUDE.md b/CLAUDE.md index b1cbf06..692e8f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ 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 @@ -33,11 +34,13 @@ 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) +- `/components` - Reusable React components (Header, Footer, Dropdown, Toast) - `/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 @@ -63,6 +66,12 @@ 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) @@ -73,19 +82,24 @@ 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 +- `POST /api/addStream` - Add new stream to database and create browser source in OBS (accepts Twitch username, auto-generates URL) - `GET /api/streams` - Get all available streams -- `GET /api/streams/[id]` - Individual stream operations +- `GET /api/streams/[id]` - Get individual stream details +- `DELETE /api/streams/[id]` - Delete stream from both OBS and database with confirmation #### 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/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/getTeamName` - Get team name by ID -- `POST /api/createGroup` - Create OBS group from team +- `POST /api/createGroup` - Create OBS group from team and store UUID - `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 @@ -94,7 +108,13 @@ 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 +- `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) ### OBS Integration Pattern @@ -102,7 +122,11 @@ 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. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management +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 **Required OBS Source Switchers** (must be created with these exact names): - `ss_large` - Large screen source switcher @@ -123,12 +147,26 @@ 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 @@ -142,4 +180,36 @@ 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 \ No newline at end of file +**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 diff --git a/app/api/createGroup/route.ts b/app/api/createGroup/route.ts index 10c3c44..af86197 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' }); - // Update team with group name - await db.run( - `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, - [sanitizedGroupName, validTeamId] - ); - - // Create group in OBS + // Create group in OBS first to get UUID const result = await createGroupIfNotExists(sanitizedGroupName); + // Update team with group name and UUID + await db.run( + `UPDATE ${teamsTableName} SET group_name = ?, group_uuid = ? WHERE team_id = ?`, + [sanitizedGroupName, result.sceneUuid, validTeamId] + ); + await db.close(); return NextResponse.json({ diff --git a/app/api/teams/[teamId]/route.ts b/app/api/teams/[teamId]/route.ts index a132725..74582f4 100644 --- a/app/api/teams/[teamId]/route.ts +++ b/app/api/teams/[teamId]/route.ts @@ -9,17 +9,40 @@ export async function PUT( try { const { teamId: teamIdParam } = await params; const teamId = parseInt(teamIdParam); - const { team_name } = await request.json(); + const body = await request.json(); + const { team_name, group_name, group_uuid } = body; - if (!team_name) { - return NextResponse.json({ error: 'Team name is required' }, { status: 400 }); + // 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 }); } 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 team_name = ? WHERE team_id = ?`, - [team_name, teamId] + `UPDATE ${TABLE_NAMES.TEAMS} SET ${updates.join(', ')} WHERE team_id = ?`, + values ); if (result.changes === 0) { diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 36addd1..2ccdaf0 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 FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); + const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name, group_uuid FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); return createSuccessResponse(teams); } catch (error) { @@ -86,7 +86,8 @@ export const POST = withErrorHandling(async (request: Request) => { const newTeam: Team = { team_id: result.lastID!, team_name: team_name, - group_name: null + group_name: null, + group_uuid: null }; return createSuccessResponse(newTeam, 201); diff --git a/app/api/verifyGroups/route.ts b/app/api/verifyGroups/route.ts new file mode 100644 index 0000000..df210fb --- /dev/null +++ b/app/api/verifyGroups/route.ts @@ -0,0 +1,85 @@ +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 bbbc38a..7b877e4 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -5,9 +5,22 @@ 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(''); @@ -38,6 +51,35 @@ 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(); @@ -193,6 +235,7 @@ 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(); @@ -206,6 +249,58 @@ 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); @@ -271,15 +366,26 @@ export default function Teams() {

Existing Teams

- +
+ + +
{isLoading ? ( @@ -297,7 +403,10 @@ export default function Teams() {
) : (
- {teams.map((team) => ( + {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 (
{editingTeam?.team_id === team.team_id ? (
@@ -338,14 +447,27 @@ export default function Teams() {
{team.team_name}
ID: {team.team_id}
{team.group_name ? ( -
OBS Group: {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 + )} +
) : (
No OBS Group
)}
- {!team.group_name && ( + {shouldShowCreateButton && ( )} + {verification && !verification.exists_in_obs && ( + + )} + {verification && verification.name_changed && verification.current_name && ( + + )}
)}
- ))} + ); + })} )} diff --git a/lib/apiHelpers.ts b/lib/apiHelpers.ts index d6c5687..7373f17 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 (_error) { + } catch { return { success: false, response: createErrorResponse( diff --git a/lib/obsClient.js b/lib/obsClient.js index 5465302..4d323b3 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -124,17 +124,30 @@ async function createGroupIfNotExists(groupName) { try { const obsClient = await getOBSClient(); - // Check if the group (scene) exists + // Check if the group (scene) exists and get its UUID const { scenes } = await obsClient.call('GetSceneList'); - const groupExists = scenes.some((scene) => scene.sceneName === groupName); + const existingScene = scenes.find((scene) => scene.sceneName === groupName); - if (!groupExists) { + if (!existingScene) { console.log(`Creating group "${groupName}"`); await obsClient.call('CreateScene', { sceneName: groupName }); - return { created: true, message: `Group "${groupName}" created successfully` }; + + // 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 + }; } else { console.log(`Group "${groupName}" already exists`); - return { created: false, message: `Group "${groupName}" already exists` }; + return { + created: false, + message: `Group "${groupName}" already exists`, + sceneUuid: existingScene.sceneUuid + }; } } catch (error) { console.error('Error creating group:', error.message); diff --git a/lib/performance.ts b/lib/performance.ts index c523293..fa8a079 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -193,5 +193,6 @@ 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 0102daa..7af2f60 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --coverage --watchAll=false", - "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts" + "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts", + "add-group-uuid-column": "tsx scripts/addGroupUuidColumn.ts" }, "dependencies": { "@tailwindcss/postcss": "^4.1.11", diff --git a/scripts/addGroupUuidColumn.ts b/scripts/addGroupUuidColumn.ts new file mode 100644 index 0000000..949af9e --- /dev/null +++ b/scripts/addGroupUuidColumn.ts @@ -0,0 +1,48 @@ +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 fcb17f1..8933587 100644 --- a/types/index.ts +++ b/types/index.ts @@ -15,4 +15,5 @@ export type Team = { team_id: number; team_name: string; group_name?: string | null; + group_uuid?: string | null; }; \ No newline at end of file