Add OBS group management feature and documentation
Some checks failed
Lint and Build / build (22) (pull_request) Failing after 32s
Lint and Build / build (20) (pull_request) Failing after 34s

- 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 <noreply@anthropic.com>
This commit is contained in:
Decobus 2025-07-20 00:28:16 -04:00
parent c259f0d943
commit 5789986bb6
14 changed files with 540 additions and 144 deletions

View file

@ -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<string, unknown>) => Promise<Record<string, unknown>>;
}
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})

View file

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

View file

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

View file

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