Implement UUID-based tracking for OBS groups #4

Merged
deco merged 3 commits from obs-uuid-tracking into main 2025-07-20 22:55:32 +03:00
12 changed files with 430 additions and 42 deletions

View file

@ -17,6 +17,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
### Database Management ### Database Management
- `npm run create-sat-summer-2025-tables` - Create database tables with seasonal naming convention - `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 ## 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) - `/streams` - Streams management page (add new streams and view existing)
- `/teams` - Team management page - `/teams` - Team management page
- `/edit/[id]` - Individual stream editing - `/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 - `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management - `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client with persistent connection management - `obsClient.js` - OBS WebSocket client with persistent connection management
- `constants.ts` - Dynamic table naming system for seasonal deployments - `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 - `/types` - TypeScript type definitions
- `/files` - Default directory for SQLite database and text files (configurable via .env.local) - `/files` - Default directory for SQLite database and text files (configurable via .env.local)
- `/scripts` - Database setup and management scripts - `/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 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 ### Environment Configuration
- `FILE_DIRECTORY`: Directory for database and text files (default: ./files) - `FILE_DIRECTORY`: Directory for database and text files (default: ./files)
- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) - `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 ### API Endpoints
#### Stream Management #### 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` - 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 #### Source Control
- `POST /api/setActive` - Set active stream for specific screen position - `POST /api/setActive` - Set active stream for specific screen position
- `GET /api/getActive` - Get currently active sources for all screens - `GET /api/getActive` - Get currently active sources for all screens
#### Team Management #### 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 - `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 - `POST /api/syncGroups` - Synchronize all teams with OBS groups
- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking
#### System Status #### System Status
- `GET /api/obsStatus` - Real-time OBS connection and streaming 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: Dynamic table names with seasonal configuration:
- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id - `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 ### 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 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 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): **Required OBS Source Switchers** (must be created with these exact names):
- `ss_large` - Large screen source switcher - `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. **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 ### Component Patterns
- **Client Components**: All interactive components use `'use client'` directive for React 19 compatibility - **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 - **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 - **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 - **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 ### Security Architecture
@ -143,3 +181,35 @@ 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 **Path Protection**: File operations are restricted to allowlisted screen names, preventing directory traversal
**Error Handling**: Secure error responses that don't leak system information **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

View file

@ -43,15 +43,15 @@ export async function POST(request: NextRequest) {
suffix: 'sat' suffix: 'sat'
}); });
// Update team with group name // Create group in OBS first to get UUID
await db.run(
`UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`,
[sanitizedGroupName, validTeamId]
);
// Create group in OBS
const result = await createGroupIfNotExists(sanitizedGroupName); 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(); await db.close();
return NextResponse.json({ return NextResponse.json({

View file

@ -9,17 +9,40 @@ export async function PUT(
try { try {
const { teamId: teamIdParam } = await params; const { teamId: teamIdParam } = await params;
const teamId = parseInt(teamIdParam); 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) { // Allow updating any combination of fields
return NextResponse.json({ error: 'Team name is required' }, { status: 400 }); 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(); 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( const result = await db.run(
`UPDATE ${TABLE_NAMES.TEAMS} SET team_name = ? WHERE team_id = ?`, `UPDATE ${TABLE_NAMES.TEAMS} SET ${updates.join(', ')} WHERE team_id = ?`,
[team_name, teamId] values
); );
if (result.changes === 0) { if (result.changes === 0) {

View file

@ -45,7 +45,7 @@ function validateTeamInput(data: unknown): {
export const GET = withErrorHandling(async () => { export const GET = withErrorHandling(async () => {
try { try {
const db = await getDatabase(); 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); return createSuccessResponse(teams);
} catch (error) { } catch (error) {
@ -86,7 +86,8 @@ export const POST = withErrorHandling(async (request: Request) => {
const newTeam: Team = { const newTeam: Team = {
team_id: result.lastID!, team_id: result.lastID!,
team_name: team_name, team_name: team_name,
group_name: null group_name: null,
group_uuid: null
}; };
return createSuccessResponse(newTeam, 201); return createSuccessResponse(newTeam, 201);

View file

@ -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 }
);
}
}

View file

@ -5,9 +5,22 @@ import { Team } from '@/types';
import { useToast } from '@/lib/useToast'; import { useToast } from '@/lib/useToast';
import { ToastContainer } from '@/components/Toast'; 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() { export default function Teams() {
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [groupVerification, setGroupVerification] = useState<GroupVerification[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isVerifying, setIsVerifying] = useState(false);
const [newTeamName, setNewTeamName] = useState(''); const [newTeamName, setNewTeamName] = useState('');
const [editingTeam, setEditingTeam] = useState<Team | null>(null); const [editingTeam, setEditingTeam] = useState<Team | null>(null);
const [editingName, setEditingName] = useState(''); 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) => { const handleAddTeam = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -193,6 +235,7 @@ export default function Teams() {
if (res.ok) { if (res.ok) {
fetchTeams(); fetchTeams();
verifyGroups(); // Refresh verification after creating
showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`); showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`);
} else { } else {
const error = await res.json(); 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) => { const startEditing = (team: Team) => {
setEditingTeam(team); setEditingTeam(team);
setEditingName(team.team_name); setEditingName(team.team_name);
@ -271,15 +366,26 @@ export default function Teams() {
<div className="glass p-6"> <div className="glass p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="card-title">Existing Teams</h2> <h2 className="card-title">Existing Teams</h2>
<button <div className="button-group">
onClick={handleSyncAllGroups} <button
disabled={isSyncing || isLoading} onClick={verifyGroups}
className="btn btn-success" disabled={isVerifying || isLoading}
title="Create OBS groups for all teams without groups" className="btn btn-secondary"
> title="Check if database groups exist in OBS"
<span className="icon">🔄</span> >
{isSyncing ? 'Syncing...' : 'Sync All Groups'} <span className="icon">🔍</span>
</button> {isVerifying ? 'Verifying...' : 'Verify Groups'}
</button>
<button
onClick={handleSyncAllGroups}
disabled={isSyncing || isLoading}
className="btn btn-success"
title="Create OBS groups for all teams without groups"
>
<span className="icon">🔄</span>
{isSyncing ? 'Syncing...' : 'Sync All Groups'}
</button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -297,7 +403,10 @@ export default function Teams() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{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 (
<div key={team.team_id} className="glass p-4 mb-4"> <div key={team.team_id} className="glass p-4 mb-4">
{editingTeam?.team_id === team.team_id ? ( {editingTeam?.team_id === team.team_id ? (
<div className="form-row"> <div className="form-row">
@ -338,14 +447,27 @@ export default function Teams() {
<div className="font-semibold text-white">{team.team_name}</div> <div className="font-semibold text-white">{team.team_name}</div>
<div className="text-sm text-white/60">ID: {team.team_id}</div> <div className="text-sm text-white/60">ID: {team.team_id}</div>
{team.group_name ? ( {team.group_name ? (
<div className="text-sm text-green-400">OBS Group: {team.group_name}</div> <div className="text-sm">
<span className={verification && !verification.exists_in_obs ? 'text-red-400' : 'text-green-400'}>
OBS Group: {verification?.current_name || team.group_name}
</span>
{verification && !verification.exists_in_obs && (
<span className="text-red-400 ml-2"> Not found in OBS</span>
)}
{verification && verification.name_changed && (
<span className="text-yellow-400 ml-2">📝 Name changed in OBS</span>
)}
{verification?.matched_by === 'uuid' && (
<span className="text-blue-400 ml-2">🆔 Linked by UUID</span>
)}
</div>
) : ( ) : (
<div className="text-sm text-orange-400">No OBS Group</div> <div className="text-sm text-orange-400">No OBS Group</div>
)} )}
</div> </div>
</div> </div>
<div className="button-group"> <div className="button-group">
{!team.group_name && ( {shouldShowCreateButton && (
<button <button
onClick={() => handleCreateGroup(team.team_id, team.team_name)} onClick={() => handleCreateGroup(team.team_id, team.team_name)}
disabled={creatingGroupForTeam === team.team_id || deletingTeamId === team.team_id || updatingTeamId === team.team_id} disabled={creatingGroupForTeam === team.team_id || deletingTeamId === team.team_id || updatingTeamId === team.team_id}
@ -356,6 +478,28 @@ export default function Teams() {
{creatingGroupForTeam === team.team_id ? 'Creating...' : 'Create Group'} {creatingGroupForTeam === team.team_id ? 'Creating...' : 'Create Group'}
</button> </button>
)} )}
{verification && !verification.exists_in_obs && (
<button
onClick={() => handleClearInvalidGroup(team.team_id, team.team_name)}
disabled={updatingTeamId === team.team_id || deletingTeamId === team.team_id}
className="btn-danger btn-sm"
title="Clear invalid group assignment"
>
<span className="icon">🗑</span>
Clear Invalid
</button>
)}
{verification && verification.name_changed && verification.current_name && (
<button
onClick={() => handleUpdateGroupName(team.team_id, team.team_name, verification.current_name!)}
disabled={updatingTeamId === team.team_id || deletingTeamId === team.team_id}
className="btn btn-secondary btn-sm"
title="Update database to match OBS name"
>
<span className="icon">📝</span>
Update Name
</button>
)}
<button <button
onClick={() => startEditing(team)} onClick={() => startEditing(team)}
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id} disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
@ -378,7 +522,8 @@ export default function Teams() {
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View file

@ -140,7 +140,7 @@ export async function parseRequestBody<T>(
} }
return { success: true, data: body as T }; return { success: true, data: body as T };
} catch (_error) { } catch {
return { return {
success: false, success: false,
response: createErrorResponse( response: createErrorResponse(

View file

@ -124,17 +124,30 @@ async function createGroupIfNotExists(groupName) {
try { try {
const obsClient = await getOBSClient(); 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 { 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}"`); console.log(`Creating group "${groupName}"`);
await obsClient.call('CreateScene', { sceneName: 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 { } else {
console.log(`Group "${groupName}" already exists`); 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) { } catch (error) {
console.error('Error creating group:', error.message); console.error('Error creating group:', error.message);

View file

@ -193,5 +193,6 @@ export function useSmartPolling(
intervalRef.current = null; intervalRef.current = null;
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [interval, isVisible, ...dependencies]); }, [interval, isVisible, ...dependencies]);
} }

View file

@ -12,7 +12,8 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"test:ci": "jest --coverage --watchAll=false", "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": { "dependencies": {
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",

View file

@ -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);
});

View file

@ -15,4 +15,5 @@ export type Team = {
team_id: number; team_id: number;
team_name: string; team_name: string;
group_name?: string | null; group_name?: string | null;
group_uuid?: string | null;
}; };