Add OBS group management feature and documentation
- 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:
parent
c259f0d943
commit
5789986bb6
14 changed files with 540 additions and 144 deletions
11
CLAUDE.md
11
CLAUDE.md
|
@ -101,6 +101,17 @@ The app uses a sophisticated dual integration approach:
|
||||||
1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
|
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
|
||||||
|
|
||||||
|
**Required OBS Source Switchers** (must be created with these exact names):
|
||||||
|
- `ss_large` - Large screen source switcher
|
||||||
|
- `ss_left` - Left screen source switcher
|
||||||
|
- `ss_right` - Right screen source switcher
|
||||||
|
- `ss_top_left` - Top left screen source switcher
|
||||||
|
- `ss_top_right` - Top right screen source switcher
|
||||||
|
- `ss_bottom_left` - Bottom left screen source switcher
|
||||||
|
- `ss_bottom_right` - Bottom right screen source switcher
|
||||||
|
|
||||||
|
See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructions.
|
||||||
|
|
||||||
**Source Control Workflow**:
|
**Source Control Workflow**:
|
||||||
1. User selects stream in React UI
|
1. User selects stream in React UI
|
||||||
2. API writes source name to position-specific text file (e.g., `large.txt`, `left.txt`)
|
2. API writes source name to position-specific text file (e.g., `large.txt`, `left.txt`)
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../lib/database';
|
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 {
|
interface OBSClient {
|
||||||
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OBSScene {
|
|
||||||
sceneName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OBSInput {
|
interface OBSInput {
|
||||||
inputName: string;
|
inputName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetSceneListResponse {
|
|
||||||
currentProgramSceneName: string;
|
|
||||||
currentPreviewSceneName: string;
|
|
||||||
scenes: OBSScene[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetInputListResponse {
|
interface GetInputListResponse {
|
||||||
inputs: OBSInput[];
|
inputs: OBSInput[];
|
||||||
|
@ -33,85 +28,38 @@ const screens = [
|
||||||
'ss_bottom_right',
|
'ss_bottom_right',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchTeamName(teamId: number) {
|
async function fetchTeamInfo(teamId: number) {
|
||||||
|
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||||
try {
|
try {
|
||||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
|
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
||||||
const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`);
|
const db = await open({
|
||||||
if (!response.ok) {
|
filename: dbPath,
|
||||||
throw new Error('Failed to fetch team name');
|
driver: sqlite3.Database,
|
||||||
}
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Browser source "${inputName}" created successfully.`);
|
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
||||||
|
year: 2025,
|
||||||
// Step 2: Wait for the input to initialize
|
season: 'summer',
|
||||||
let inputReady = false;
|
suffix: 'sat'
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 db.close();
|
||||||
await obs.call('SetInputMute', {
|
return teamInfo;
|
||||||
inputName,
|
} catch (error) {
|
||||||
inputMuted: true,
|
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';
|
import { validateStreamInput } from '../../../lib/security';
|
||||||
|
|
||||||
|
@ -160,20 +108,23 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
throw new Error('GetInputList failed.');
|
throw new Error('GetInputList failed.');
|
||||||
}
|
}
|
||||||
const teamName = await fetchTeamName(team_id);
|
|
||||||
console.log('Team Name:', teamName)
|
const teamInfo = await fetchTeamInfo(team_id);
|
||||||
const response = await obs.call('GetSceneList');
|
if (!teamInfo) {
|
||||||
const sceneListResponse = response as unknown as GetSceneListResponse;
|
throw new Error('Team not found');
|
||||||
const { scenes } = sceneListResponse;
|
|
||||||
const groupExists = scenes.some((scene: OBSScene) => scene.sceneName === teamName);
|
|
||||||
if (!groupExists) {
|
|
||||||
await obs.call('CreateScene', { sceneName: teamName });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
|
||||||
|
|
||||||
if (!sourceExists) {
|
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.`);
|
console.log(`OBS source "${obs_source_name}" created.`);
|
||||||
|
|
||||||
|
@ -196,7 +147,12 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDatabase();
|
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])
|
db.run(query, [name, obs_source_name, url, team_id])
|
||||||
await disconnectFromOBS();
|
await disconnectFromOBS();
|
||||||
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
|
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
|
||||||
|
|
67
app/api/createGroup/route.ts
Normal file
67
app/api/createGroup/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
81
app/api/syncGroups/route.ts
Normal file
81
app/api/syncGroups/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getDatabase } from '../../../lib/database';
|
import { getDatabase } from '../../../lib/database';
|
||||||
import { Team } from '@/types';
|
import { Team } from '@/types';
|
||||||
import { TABLE_NAMES } from '@/lib/constants';
|
import { TABLE_NAMES } from '@/lib/constants';
|
||||||
|
@ -39,14 +38,14 @@ function validateTeamInput(data: unknown): {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
data: { team_name: team_name.trim() }
|
data: { team_name: (team_name as string).trim() }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 * 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);
|
return createSuccessResponse(teams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -86,7 +85,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
|
||||||
};
|
};
|
||||||
|
|
||||||
return createSuccessResponse(newTeam, 201);
|
return createSuccessResponse(newTeam, 201);
|
||||||
|
|
|
@ -11,6 +11,8 @@ export default function Teams() {
|
||||||
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('');
|
||||||
|
const [creatingGroupForTeam, setCreatingGroupForTeam] = useState<number | null>(null);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [updatingTeamId, setUpdatingTeamId] = useState<number | null>(null);
|
const [updatingTeamId, setUpdatingTeamId] = useState<number | null>(null);
|
||||||
const [deletingTeamId, setDeletingTeamId] = useState<number | null>(null);
|
const [deletingTeamId, setDeletingTeamId] = useState<number | null>(null);
|
||||||
|
@ -146,6 +148,64 @@ export default function Teams() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSyncAllGroups = async () => {
|
||||||
|
if (!confirm('This will create OBS groups for all teams that don\'t have one. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/syncGroups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json();
|
||||||
|
fetchTeams();
|
||||||
|
showSuccess('Groups Synced', `${result.summary.successful} groups created successfully`);
|
||||||
|
if (result.summary.failed > 0) {
|
||||||
|
showError('Some Failures', `${result.summary.failed} groups failed to create`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
showError('Failed to Sync Groups', error.error || 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing groups:', error);
|
||||||
|
showError('Failed to Sync Groups', 'Network error or server unavailable');
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateGroup = async (teamId: number, teamName: string) => {
|
||||||
|
const groupName = prompt(`Enter group name for team "${teamName}":`, teamName);
|
||||||
|
if (!groupName) return;
|
||||||
|
|
||||||
|
setCreatingGroupForTeam(teamId);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/createGroup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ teamId, groupName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
fetchTeams();
|
||||||
|
showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`);
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
showError('Failed to Create Group', error.error || 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating group:', error);
|
||||||
|
showError('Failed to Create Group', 'Network error or server unavailable');
|
||||||
|
} finally {
|
||||||
|
setCreatingGroupForTeam(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const startEditing = (team: Team) => {
|
const startEditing = (team: Team) => {
|
||||||
setEditingTeam(team);
|
setEditingTeam(team);
|
||||||
setEditingName(team.team_name);
|
setEditingName(team.team_name);
|
||||||
|
@ -209,7 +269,18 @@ export default function Teams() {
|
||||||
|
|
||||||
{/* Teams List */}
|
{/* Teams List */}
|
||||||
<div className="glass p-6">
|
<div className="glass p-6">
|
||||||
<h2 className="card-title">Existing Teams</h2>
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="card-title">Existing Teams</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
|
@ -227,7 +298,7 @@ export default function Teams() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<div key={team.team_id} className="glass p-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">
|
||||||
<input
|
<input
|
||||||
|
@ -266,9 +337,25 @@ export default function Teams() {
|
||||||
<div>
|
<div>
|
||||||
<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 ? (
|
||||||
|
<div className="text-sm text-green-400">OBS Group: {team.group_name}</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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCreateGroup(team.team_id, team.team_name)}
|
||||||
|
disabled={creatingGroupForTeam === team.team_id || deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
||||||
|
className="btn-success btn-sm"
|
||||||
|
title="Create OBS group"
|
||||||
|
>
|
||||||
|
<span className="icon">🎬</span>
|
||||||
|
{creatingGroupForTeam === team.team_id ? 'Creating...' : 'Create Group'}
|
||||||
|
</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}
|
||||||
|
|
99
docs/OBS_SETUP.md
Normal file
99
docs/OBS_SETUP.md
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
# OBS Source Switcher Setup Guide
|
||||||
|
|
||||||
|
This document explains how to configure OBS Studio to work with the Source Switcher Plugin UI.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. OBS Studio installed
|
||||||
|
2. [OBS WebSocket plugin](https://github.com/obsproject/obs-websocket) (usually included with OBS 28+)
|
||||||
|
3. [OBS Source Switcher plugin](https://obsproject.com/forum/resources/source-switcher.1090/) installed
|
||||||
|
|
||||||
|
## Required Source Switcher Names
|
||||||
|
|
||||||
|
You must create **exactly 7 Source Switcher sources** in OBS with these specific names:
|
||||||
|
|
||||||
|
| Source Switcher Name | Screen Position | Text File |
|
||||||
|
|---------------------|-----------------|-----------|
|
||||||
|
| `ss_large` | Main/Large screen | `large.txt` |
|
||||||
|
| `ss_left` | Left screen | `left.txt` |
|
||||||
|
| `ss_right` | Right screen | `right.txt` |
|
||||||
|
| `ss_top_left` | Top left corner | `topLeft.txt` |
|
||||||
|
| `ss_top_right` | Top right corner | `topRight.txt` |
|
||||||
|
| `ss_bottom_left` | Bottom left corner | `bottomLeft.txt` |
|
||||||
|
| `ss_bottom_right` | Bottom right corner | `bottomRight.txt` |
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Configure OBS WebSocket
|
||||||
|
|
||||||
|
1. In OBS, go to **Tools → WebSocket Server Settings**
|
||||||
|
2. Enable the WebSocket server
|
||||||
|
3. Set a port (default: 4455)
|
||||||
|
4. Optionally set a password
|
||||||
|
5. Note these settings for your `.env.local` file
|
||||||
|
|
||||||
|
### 2. Create Source Switcher Sources
|
||||||
|
|
||||||
|
For each screen position:
|
||||||
|
|
||||||
|
1. In OBS, click the **+** button in Sources
|
||||||
|
2. Select **Source Switcher**
|
||||||
|
3. Name it exactly as shown in the table above (e.g., `ss_large`)
|
||||||
|
4. Configure the Source Switcher:
|
||||||
|
- **Mode**: Text File
|
||||||
|
- **File Path**: Point to the corresponding text file in your `files` directory
|
||||||
|
- **Switch Behavior**: Choose your preferred transition
|
||||||
|
|
||||||
|
### 3. Configure Text File Monitoring
|
||||||
|
|
||||||
|
Each Source Switcher should monitor its corresponding text file:
|
||||||
|
|
||||||
|
- `ss_large` → monitors `{FILE_DIRECTORY}/large.txt`
|
||||||
|
- `ss_left` → monitors `{FILE_DIRECTORY}/left.txt`
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
Where `{FILE_DIRECTORY}` is the path configured in your `.env.local` file (default: `./files`)
|
||||||
|
|
||||||
|
### 4. Add Browser Sources
|
||||||
|
|
||||||
|
When you add streams through the UI, browser sources are automatically created in OBS with these settings:
|
||||||
|
- **Width**: 1600px
|
||||||
|
- **Height**: 900px
|
||||||
|
- **Audio**: Controlled via OBS (muted by default)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Stream Selection**: When you select a stream for a screen position in the UI
|
||||||
|
2. **File Update**: The app writes the OBS source name to the corresponding text file
|
||||||
|
3. **Source Switch**: The Source Switcher detects the file change and switches to that source
|
||||||
|
4. **Group Organization**: Streams are organized into OBS groups based on their teams
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Source Switcher not switching
|
||||||
|
- Verify the text file path is correct
|
||||||
|
- Check that the file is being updated (manually open the .txt file)
|
||||||
|
- Ensure Source Switcher is set to "Text File" mode
|
||||||
|
|
||||||
|
### Sources not appearing
|
||||||
|
- Check OBS WebSocket connection in the footer
|
||||||
|
- Verify WebSocket credentials in `.env.local`
|
||||||
|
- Ensure the source name doesn't already exist in OBS
|
||||||
|
|
||||||
|
### Missing screen positions
|
||||||
|
- Verify all 7 Source Switchers are created with exact names
|
||||||
|
- Check for typos in source names (they must match exactly)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure these in your `.env.local` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# OBS WebSocket Settings
|
||||||
|
OBS_WEBSOCKET_HOST=127.0.0.1
|
||||||
|
OBS_WEBSOCKET_PORT=4455
|
||||||
|
OBS_WEBSOCKET_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# File Directory (where text files are stored)
|
||||||
|
FILE_DIRECTORY=./files
|
||||||
|
```
|
|
@ -18,7 +18,7 @@ export async function apiCall(url: string, options: RequestInit = {}): Promise<R
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...(options.headers as Record<string, string> || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add API key if available
|
// Add API key if available
|
||||||
|
|
110
lib/obsClient.js
110
lib/obsClient.js
|
@ -120,48 +120,74 @@ async function addSourceToSwitcher(inputName, newSources) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// async function addSourceToGroup(obs, teamName, obs_source_name, url) {
|
async function createGroupIfNotExists(groupName) {
|
||||||
// try {
|
try {
|
||||||
// // Step 1: Check if the group exists
|
const obsClient = await getOBSClient();
|
||||||
// const { scenes } = await obs.call('GetSceneList');
|
|
||||||
// const groupExists = scenes.some((scene) => scene.sceneName === teamName);
|
// Check if the group (scene) exists
|
||||||
|
const { scenes } = await obsClient.call('GetSceneList');
|
||||||
|
const groupExists = scenes.some((scene) => scene.sceneName === groupName);
|
||||||
|
|
||||||
// // Step 2: Create the group if it doesn't exist
|
if (!groupExists) {
|
||||||
// if (!groupExists) {
|
console.log(`Creating group "${groupName}"`);
|
||||||
// console.log(`Group "${teamName}" does not exist. Creating it.`);
|
await obsClient.call('CreateScene', { sceneName: groupName });
|
||||||
// await obs.call('CreateScene', { sceneName: teamName });
|
return { created: true, message: `Group "${groupName}" created successfully` };
|
||||||
// } else {
|
} else {
|
||||||
// console.log(`Group "${teamName}" already exists.`);
|
console.log(`Group "${groupName}" already exists`);
|
||||||
// }
|
return { created: false, message: `Group "${groupName}" already exists` };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating group:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// // Step 3: Add the source to the group
|
async function addSourceToGroup(groupName, sourceName, url) {
|
||||||
// console.log(`Adding source "${obs_source_name}" to group "${teamName}".`);
|
try {
|
||||||
// await obs.call('CreateInput', {
|
const obsClient = await getOBSClient();
|
||||||
// sceneName: teamName,
|
|
||||||
// inputName: obs_source_name,
|
// Ensure group exists
|
||||||
// inputKind: 'browser_source',
|
await createGroupIfNotExists(groupName);
|
||||||
// inputSettings: {
|
|
||||||
// width: 1600,
|
// Check if source already exists in the group
|
||||||
// height: 900,
|
const { sceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
|
||||||
// url,
|
const sourceExists = sceneItems.some(item => item.sourceName === sourceName);
|
||||||
// control_audio: true,
|
|
||||||
// },
|
if (!sourceExists) {
|
||||||
// });
|
// Create the browser source in the group
|
||||||
|
console.log(`Adding source "${sourceName}" to group "${groupName}"`);
|
||||||
// // Step 4: Enable "Control audio via OBS"
|
await obsClient.call('CreateInput', {
|
||||||
// await obs.call('SetInputSettings', {
|
sceneName: groupName,
|
||||||
// inputName: obs_source_name,
|
inputName: sourceName,
|
||||||
// inputSettings: {
|
inputKind: 'browser_source',
|
||||||
// control_audio: true, // Enable audio control
|
inputSettings: {
|
||||||
// },
|
width: 1600,
|
||||||
// overlay: true, // Keep existing settings and apply changes
|
height: 900,
|
||||||
// });
|
url,
|
||||||
|
control_audio: true,
|
||||||
// console.log(`Source "${obs_source_name}" successfully added to group "${teamName}".`);
|
},
|
||||||
// } catch (error) {
|
});
|
||||||
// console.error('Error adding source to group:', error.message);
|
|
||||||
// }
|
// Ensure audio control is enabled
|
||||||
// }
|
await obsClient.call('SetInputSettings', {
|
||||||
|
inputName: sourceName,
|
||||||
|
inputSettings: {
|
||||||
|
control_audio: true,
|
||||||
|
},
|
||||||
|
overlay: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Source "${sourceName}" successfully added to group "${groupName}"`);
|
||||||
|
return { success: true, message: `Source added to group successfully` };
|
||||||
|
} else {
|
||||||
|
console.log(`Source "${sourceName}" already exists in group "${groupName}"`);
|
||||||
|
return { success: false, message: `Source already exists in group` };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding source to group:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Export all functions
|
// Export all functions
|
||||||
|
@ -171,5 +197,7 @@ module.exports = {
|
||||||
disconnectFromOBS,
|
disconnectFromOBS,
|
||||||
addSourceToSwitcher,
|
addSourceToSwitcher,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
getConnectionStatus
|
getConnectionStatus,
|
||||||
|
createGroupIfNotExists,
|
||||||
|
addSourceToGroup
|
||||||
};
|
};
|
|
@ -7,7 +7,7 @@ export function useDebounce<T extends (...args: any[]) => any>(
|
||||||
callback: T,
|
callback: T,
|
||||||
delay: number
|
delay: number
|
||||||
): T {
|
): T {
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
return useCallback((...args: Parameters<T>) => {
|
return useCallback((...args: Parameters<T>) => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
|
@ -159,7 +159,7 @@ export function useSmartPolling(
|
||||||
) {
|
) {
|
||||||
const isVisible = usePageVisibility();
|
const isVisible = usePageVisibility();
|
||||||
const callbackRef = useRef(callback);
|
const callbackRef = useRef(callback);
|
||||||
const intervalRef = useRef<NodeJS.Timeout>();
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Update callback ref
|
// Update callback ref
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
@ -18,7 +18,15 @@ export function isValidUrl(url: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPositiveInteger(value: unknown): value is number {
|
export function isPositiveInteger(value: unknown): value is number {
|
||||||
return Number.isInteger(value) && value > 0;
|
return Number.isInteger(value) && Number(value) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateInteger(value: unknown): number | null {
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isInteger(num) && num > 0) {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// String sanitization
|
// String sanitization
|
||||||
|
|
57
scripts/addGroupNameToTeams.ts
Normal file
57
scripts/addGroupNameToTeams.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { open } from 'sqlite';
|
||||||
|
import path from 'path';
|
||||||
|
import { getTableName, BASE_TABLE_NAMES } from '../lib/constants';
|
||||||
|
|
||||||
|
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||||
|
|
||||||
|
const addGroupNameToTeams = async () => {
|
||||||
|
try {
|
||||||
|
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
||||||
|
|
||||||
|
// Open database connection
|
||||||
|
const db = await open({
|
||||||
|
filename: dbPath,
|
||||||
|
driver: sqlite3.Database,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Database connection established.');
|
||||||
|
|
||||||
|
// Generate table name for teams
|
||||||
|
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
||||||
|
year: 2025,
|
||||||
|
season: 'summer',
|
||||||
|
suffix: 'sat'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Adding group_name column to ${teamsTableName}`);
|
||||||
|
|
||||||
|
// Check if column already exists
|
||||||
|
const tableInfo = await db.all(`PRAGMA table_info(${teamsTableName})`);
|
||||||
|
const hasGroupName = tableInfo.some(col => col.name === 'group_name');
|
||||||
|
|
||||||
|
if (!hasGroupName) {
|
||||||
|
// Add group_name column
|
||||||
|
await db.exec(`
|
||||||
|
ALTER TABLE ${teamsTableName}
|
||||||
|
ADD COLUMN group_name TEXT
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(`✅ Added group_name column to ${teamsTableName}`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ group_name column already exists in ${teamsTableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
await db.close();
|
||||||
|
console.log('Database connection closed.');
|
||||||
|
console.log('✅ Successfully updated teams table schema!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating table:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
addGroupNameToTeams();
|
|
@ -60,7 +60,8 @@ const createSatSummer2025Tables = async () => {
|
||||||
await db.exec(`
|
await db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ${teamsTableName} (
|
CREATE TABLE IF NOT EXISTS ${teamsTableName} (
|
||||||
team_id INTEGER PRIMARY KEY,
|
team_id INTEGER PRIMARY KEY,
|
||||||
team_name TEXT NOT NULL
|
team_name TEXT NOT NULL,
|
||||||
|
group_name TEXT
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
@ -14,4 +14,5 @@ export type Screen = {
|
||||||
export type Team = {
|
export type Team = {
|
||||||
team_id: number;
|
team_id: number;
|
||||||
team_name: string;
|
team_name: string;
|
||||||
|
group_name?: string | null;
|
||||||
};
|
};
|
Loading…
Add table
Add a link
Reference in a new issue