diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 750962e..4bb22d6 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; -import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup } from '../../../lib/obsClient'; +import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createStreamGroup } from '../../../lib/obsClient'; import { open } from 'sqlite'; import sqlite3 from 'sqlite3'; import path from 'path'; @@ -44,7 +44,7 @@ async function fetchTeamInfo(teamId: number) { }); const teamInfo = await db.get( - `SELECT team_name, group_name FROM ${teamsTableName} WHERE team_id = ?`, + `SELECT team_name, group_name, group_uuid FROM ${teamsTableName} WHERE team_id = ?`, [teamId] ); @@ -63,8 +63,15 @@ async function fetchTeamInfo(teamId: number) { import { validateStreamInput } from '../../../lib/security'; +// Generate OBS source name from team scene name and stream name +function generateOBSSourceName(teamSceneName: string, streamName: string): string { + const cleanTeamName = teamSceneName.toLowerCase().replace(/\s+/g, '_'); + const cleanStreamName = streamName.toLowerCase().replace(/\s+/g, '_'); + return `${cleanTeamName}_${cleanStreamName}`; +} + export async function POST(request: NextRequest) { - let name: string, obs_source_name: string, url: string, team_id: number; + let name: string, url: string, team_id: number, obs_source_name: string; // Parse and validate request body try { @@ -78,13 +85,26 @@ export async function POST(request: NextRequest) { }, { status: 400 }); } - ({ name, obs_source_name, url, team_id } = validation.data!); + ({ name, url, team_id } = validation.data!); } catch { return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); } try { + // Fetch team info first to generate proper OBS source name + 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; + + // Generate OBS source name with team scene name prefix + obs_source_name = generateOBSSourceName(groupName, name); // Connect to OBS WebSocket console.log("Pre-connect") @@ -109,29 +129,58 @@ export async function POST(request: NextRequest) { throw new Error('GetInputList failed.'); } - 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) { - // Create/ensure group exists and add source to it - await createGroupIfNotExists(groupName); - await addSourceToGroup(groupName, obs_source_name, url); + // Create stream group with text overlay + await createStreamGroup(groupName, name, teamInfo.team_name, url); + + // Update team with group UUID if not set + if (!teamInfo.group_uuid) { + const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + 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' + }); + + try { + // Get the scene UUID for the group + const obsClient = await getOBSClient(); + const { scenes } = await obsClient.call('GetSceneList'); + const scene = scenes.find((s: { sceneName: string; sceneUuid: string }) => s.sceneName === groupName); + + if (scene) { + await db.run( + `UPDATE ${teamsTableName} SET group_name = ?, group_uuid = ? WHERE team_id = ?`, + [groupName, scene.sceneUuid, team_id] + ); + console.log(`Updated team ${team_id} with group UUID: ${scene.sceneUuid}`); + } else { + console.log(`Scene "${groupName}" not found in OBS`); + } + } catch (error) { + console.error('Error updating team group UUID:', error); + } finally { + await db.close(); + } + } console.log(`OBS source "${obs_source_name}" created.`); for (const screen of screens) { try { + const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_'); + const cleanStreamName = name.toLowerCase().replace(/\s+/g, '_'); + const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`; await addSourceToSwitcher(screen, [ - { hidden: false, selected: false, value: obs_source_name }, + { hidden: false, selected: false, value: streamGroupName }, ]); } catch (error) { if (error instanceof Error) { diff --git a/app/api/getActive/route.ts b/app/api/getActive/route.ts index c8ff45d..a54b4b4 100644 --- a/app/api/getActive/route.ts +++ b/app/api/getActive/route.ts @@ -47,8 +47,8 @@ export async function GET() { const chicken = fs.existsSync(chickenPath) ? fs.readFileSync(chickenPath, 'utf-8') : null; // For SaT - // return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201}) - return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201}) + return NextResponse.json({ large, left, right, topLeft, topRight, bottomLeft, bottomRight }, {status: 201}) + // return NextResponse.json({ tank, tree, kitty, chicken }, {status: 201}) } catch (error) { console.error('Error reading active sources:', error); return NextResponse.json({ error: 'Failed to read active sources' }, {status: 500}); diff --git a/app/api/setActive/route.ts b/app/api/setActive/route.ts index a2e107a..61fb3b8 100644 --- a/app/api/setActive/route.ts +++ b/app/api/setActive/route.ts @@ -5,6 +5,7 @@ import { FILE_DIRECTORY } from '../../../config'; import { getDatabase } from '../../../lib/database'; import { Stream } from '@/types'; import { validateScreenInput } from '../../../lib/security'; +import { TABLE_NAMES } from '../../../lib/constants'; export async function POST(request: NextRequest) { // Parse and validate request body @@ -27,7 +28,7 @@ export async function POST(request: NextRequest) { try { const db = await getDatabase(); const stream: Stream | undefined = await db.get( - 'SELECT * FROM streams_2025_spring_adr WHERE id = ?', + `SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`, [id] ); @@ -37,7 +38,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Stream not found' }, { status: 400 }); } - fs.writeFileSync(filePath, stream.obs_source_name); + // Use stream group name instead of individual obs_source_name + const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`; + fs.writeFileSync(filePath, streamGroupName); return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 }); } catch (error) { console.error('Error updating active source:', error); diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 2ccdaf0..88ec53e 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -8,11 +8,12 @@ import { createDatabaseError, parseRequestBody } from '@/lib/apiHelpers'; +import { createGroupIfNotExists, createTextSource } from '@/lib/obsClient'; // Validation for team creation function validateTeamInput(data: unknown): { valid: boolean; - data?: { team_name: string }; + data?: { team_name: string; create_obs_group?: boolean }; errors?: Record } { const errors: Record = {}; @@ -22,7 +23,7 @@ function validateTeamInput(data: unknown): { return { valid: false, errors }; } - const { team_name } = data as { team_name?: unknown }; + const { team_name, create_obs_group } = data as { team_name?: unknown; create_obs_group?: unknown }; if (!team_name || typeof team_name !== 'string') { errors.team_name = 'Team name is required and must be a string'; @@ -38,7 +39,10 @@ function validateTeamInput(data: unknown): { return { valid: true, - data: { team_name: (team_name as string).trim() } + data: { + team_name: (team_name as string).trim(), + create_obs_group: create_obs_group === true + } }; } @@ -60,7 +64,7 @@ export const POST = withErrorHandling(async (request: Request) => { return bodyResult.response; } - const { team_name } = bodyResult.data; + const { team_name, create_obs_group } = bodyResult.data; try { const db = await getDatabase(); @@ -78,16 +82,37 @@ export const POST = withErrorHandling(async (request: Request) => { ); } + let groupName: string | null = null; + let groupUuid: string | null = null; + + // Create OBS group and text source if requested + if (create_obs_group) { + try { + const obsResult = await createGroupIfNotExists(team_name); + groupName = team_name; + groupUuid = obsResult.sceneUuid; + + // Create text source for the team + const textSourceName = team_name.toLowerCase().replace(/\s+/g, '_') + '_text'; + await createTextSource(team_name, textSourceName, team_name); + + console.log(`OBS group and text source created for team "${team_name}"`); + } catch (obsError) { + console.error('Error creating OBS group:', obsError); + // Continue with team creation even if OBS fails + } + } + const result = await db.run( - `INSERT INTO ${TABLE_NAMES.TEAMS} (team_name) VALUES (?)`, - [team_name] + `INSERT INTO ${TABLE_NAMES.TEAMS} (team_name, group_name, group_uuid) VALUES (?, ?, ?)`, + [team_name, groupName, groupUuid] ); const newTeam: Team = { team_id: result.lastID!, team_name: team_name, - group_name: null, - group_uuid: null + group_name: groupName, + group_uuid: groupUuid }; return createSuccessResponse(newTeam, 201); diff --git a/app/page.tsx b/app/page.tsx index 2dc242d..37f33fd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -98,10 +98,15 @@ export default function Home() { const handleSetActive = useCallback(async (screen: ScreenType, id: number | null) => { const selectedStream = streams.find((stream) => stream.id === id); + // Generate stream group name for optimistic updates + const streamGroupName = selectedStream + ? `${selectedStream.name.toLowerCase().replace(/\s+/g, '_')}_stream` + : null; + // Update local state immediately for optimistic updates setActiveSources((prev) => ({ ...prev, - [screen]: selectedStream?.obs_source_name || null, + [screen]: streamGroupName, })); // Debounced backend update @@ -205,29 +210,6 @@ export default function Home() { - {/* Manage Streams Section */} - {streams.length > 0 && ( -
-

Manage Streams

-
- {streams.map((stream) => ( -
-
-

{stream.name}

-

{stream.obs_source_name}

-
- - ✏️ - Edit - -
- ))} -
-
- )} {/* Toast Notifications */} diff --git a/app/streams/page.tsx b/app/streams/page.tsx index 75aa281..8520c19 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -17,7 +17,6 @@ interface Stream { export default function AddStream() { const [formData, setFormData] = useState({ name: '', - obs_source_name: '', twitch_username: '', team_id: null, }); @@ -125,9 +124,6 @@ export default function AddStream() { errors.name = 'Stream name must be at least 2 characters'; } - if (!formData.obs_source_name.trim()) { - errors.obs_source_name = 'OBS source name is required'; - } if (!formData.twitch_username.trim()) { errors.twitch_username = 'Twitch username is required'; @@ -162,7 +158,7 @@ export default function AddStream() { const data = await response.json(); if (response.ok) { showSuccess('Stream Added', `"${formData.name}" has been added successfully`); - setFormData({ name: '', obs_source_name: '', twitch_username: '', team_id: null }); + setFormData({ name: '', twitch_username: '', team_id: null }); setValidationErrors({}); fetchData(); } else { @@ -214,28 +210,6 @@ export default function AddStream() { )} - {/* OBS Source Name */} -
- - - {validationErrors.obs_source_name && ( -
- {validationErrors.obs_source_name} -
- )} -
{/* Twitch Username */}
@@ -315,13 +289,21 @@ export default function AddStream() { {streams.map((stream) => { const team = teams.find(t => t.id === stream.team_id); return ( -
+
-
-
+
+
{stream.name.charAt(0).toUpperCase()}
-
+
{stream.name}
OBS: {stream.obs_source_name}
Team: {team?.name || 'Unknown'}
@@ -329,12 +311,13 @@ export default function AddStream() {
ID: {stream.id}
-
+
diff --git a/files/ss_large.txt b/files/ss_large.txt new file mode 100644 index 0000000..5fc4d8d --- /dev/null +++ b/files/ss_large.txt @@ -0,0 +1 @@ +wa_stream \ No newline at end of file diff --git a/files/ss_left.txt b/files/ss_left.txt new file mode 100644 index 0000000..e69de29 diff --git a/files/ss_right.text b/files/ss_right.text new file mode 100644 index 0000000..e69de29 diff --git a/lib/obsClient.js b/lib/obsClient.js index 4d323b3..d2f5872 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -99,12 +99,22 @@ async function addSourceToSwitcher(inputName, newSources) { // Step 1: Get current input settings const { inputSettings } = await obsClient.call('GetInputSettings', { inputName }); - // console.log('Current Settings:', inputSettings); + console.log('Current Settings for', inputName, ':', inputSettings); - // Step 2: Add new sources to the sources array - const updatedSources = [...inputSettings.sources, ...newSources]; + // Step 2: Initialize sources array if it doesn't exist or is not an array + let currentSources = []; + if (Array.isArray(inputSettings.sources)) { + currentSources = inputSettings.sources; + } else if (inputSettings.sources) { + console.log('Sources is not an array, converting:', typeof inputSettings.sources); + // Try to convert if it's an object or other format + currentSources = []; + } - // Step 3: Update the settings with the new sources array + // Step 3: Add new sources to the sources array + const updatedSources = [...currentSources, ...newSources]; + + // Step 4: Update the settings with the new sources array await obsClient.call('SetInputSettings', { inputName, inputSettings: { @@ -202,6 +212,238 @@ async function addSourceToGroup(groupName, sourceName, url) { } } +async function getAvailableTextInputKind() { + try { + const obsClient = await getOBSClient(); + const { inputKinds } = await obsClient.call('GetInputKindList'); + + console.log('Available input kinds:', inputKinds); + + // Check for text input kinds in order of preference + const textKinds = ['text_gdiplus_v2', 'text_gdiplus', 'text_ft2_source_v2', 'text_ft2_source', 'text_source']; + + for (const kind of textKinds) { + if (inputKinds.includes(kind)) { + console.log(`Found text input kind: ${kind}`); + return kind; + } + } + + // Fallback - find any input kind that contains 'text' + const textKind = inputKinds.find(kind => kind.toLowerCase().includes('text')); + if (textKind) { + console.log(`Found fallback text input kind: ${textKind}`); + return textKind; + } + + throw new Error('No text input kind found'); + } catch (error) { + console.error('Error getting available input kinds:', error.message); + throw error; + } +} + +async function createTextSource(sceneName, textSourceName, text) { + try { + const obsClient = await getOBSClient(); + + // Check if text source already exists globally in OBS + const { inputs } = await obsClient.call('GetInputList'); + const existingInput = inputs.find(input => input.inputName === textSourceName); + + if (!existingInput) { + console.log(`Creating text source "${textSourceName}" in scene "${sceneName}"`); + + // Get the correct text input kind for this OBS installation + const inputKind = await getAvailableTextInputKind(); + + const inputSettings = { + text, + font: { + face: 'Arial', + size: 72, + style: 'Bold' + }, + color: 0xFFFFFFFF, // White text + outline: true, + outline_color: 0xFF000000, // Black outline + outline_size: 4 + }; + + await obsClient.call('CreateInput', { + sceneName, + inputName: textSourceName, + inputKind, + inputSettings + }); + + console.log(`Text source "${textSourceName}" created successfully with kind "${inputKind}"`); + return { success: true, message: 'Text source created successfully' }; + } else { + console.log(`Text source "${textSourceName}" already exists globally, updating settings`); + + // Update existing text source settings + const inputSettings = { + text, + font: { + face: 'Arial', + size: 72, + style: 'Bold' + }, + color: 0xFFFFFFFF, // White text + outline: true, + outline_color: 0xFF000000, // Black outline + outline_size: 4 + }; + + await obsClient.call('SetInputSettings', { + inputName: textSourceName, + inputSettings + }); + + console.log(`Text source "${textSourceName}" settings updated`); + return { success: true, message: 'Text source settings updated' }; + } + } catch (error) { + console.error('Error creating text source:', error.message); + throw error; + } +} + +async function createStreamGroup(groupName, streamName, teamName, url) { + try { + const obsClient = await getOBSClient(); + + // Ensure team scene exists + await createGroupIfNotExists(groupName); + + const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_'); + const cleanStreamName = streamName.toLowerCase().replace(/\s+/g, '_'); + const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`; + const sourceName = `${cleanGroupName}_${cleanStreamName}`; + const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text'; + + // Create a nested scene for this stream (acts as a group) + try { + await obsClient.call('CreateScene', { sceneName: streamGroupName }); + console.log(`Created nested scene "${streamGroupName}" for stream grouping`); + } catch (sceneError) { + console.log(`Nested scene "${streamGroupName}" might already exist`); + } + + // Create text source globally (reused across streams in the team) + await createTextSource(groupName, textSourceName, teamName); + + // Create browser source globally + const { inputs } = await obsClient.call('GetInputList'); + const browserSourceExists = inputs.some(input => input.inputName === sourceName); + + if (!browserSourceExists) { + await obsClient.call('CreateInput', { + sceneName: streamGroupName, // Create in the nested scene + inputName: sourceName, + inputKind: 'browser_source', + inputSettings: { + width: 1920, + height: 1080, + url, + control_audio: true, + }, + }); + console.log(`Created browser source "${sourceName}" in nested scene`); + } else { + // Add existing source to nested scene + await obsClient.call('CreateSceneItem', { + sceneName: streamGroupName, + sourceName: sourceName + }); + } + + // Add text source to nested scene + try { + await obsClient.call('CreateSceneItem', { + sceneName: streamGroupName, + sourceName: textSourceName + }); + } catch (e) { + console.log('Text source might already be in nested scene'); + } + + // Get the scene items in the nested scene + const { sceneItems: nestedSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName }); + + // Find the browser source and text source items in nested scene + const browserSourceItem = nestedSceneItems.find(item => item.sourceName === sourceName); + const textSourceItem = nestedSceneItems.find(item => item.sourceName === textSourceName); + + // Position the sources properly in the nested scene + if (browserSourceItem && textSourceItem) { + try { + // Position text overlay at top-left of the browser source + await obsClient.call('SetSceneItemTransform', { + sceneName: streamGroupName, // In the nested scene + sceneItemId: textSourceItem.sceneItemId, + sceneItemTransform: { + positionX: 10, + positionY: 10, + scaleX: 1.0, + scaleY: 1.0 + } + }); + + console.log(`Stream sources positioned in nested scene "${streamGroupName}"`); + } catch (positionError) { + console.error('Failed to position sources:', positionError.message || positionError); + } + } + + // Now add the nested scene to the team scene as a group + const { sceneItems: teamSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName }); + const nestedSceneInTeam = teamSceneItems.some(item => item.sourceName === streamGroupName); + + if (!nestedSceneInTeam) { + try { + const { sceneItemId } = await obsClient.call('CreateSceneItem', { + sceneName: groupName, + sourceName: streamGroupName, + sceneItemEnabled: true + }); + console.log(`Added nested scene "${streamGroupName}" to team scene "${groupName}"`); + + // Set bounds to 1600x900 to match the source switcher dimensions + await obsClient.call('SetSceneItemTransform', { + sceneName: groupName, + sceneItemId: sceneItemId, + sceneItemTransform: { + alignment: 5, // Center alignment + boundsAlignment: 0, // Center bounds alignment + boundsType: 'OBS_BOUNDS_SCALE_INNER', // Scale to fit inside bounds + boundsWidth: 1600, + boundsHeight: 900, + scaleX: 1.0, + scaleY: 1.0 + } + }); + console.log(`Set bounds for nested scene to 1600x900`); + } catch (e) { + console.error('Failed to add nested scene to team scene:', e.message); + } + } + + console.log(`Stream group "${streamGroupName}" created as nested scene in team "${groupName}"`); + return { + success: true, + message: 'Stream group created as nested scene', + streamGroupName, + sourceName, + textSourceName + }; + } catch (error) { + console.error('Error creating stream group:', error.message); + throw error; + } +} + // Export all functions module.exports = { @@ -212,5 +454,8 @@ module.exports = { ensureConnected, getConnectionStatus, createGroupIfNotExists, - addSourceToGroup + addSourceToGroup, + createTextSource, + createStreamGroup, + getAvailableTextInputKind }; \ No newline at end of file diff --git a/lib/performance.ts b/lib/performance.ts index fa8a079..4355393 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -43,7 +43,9 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n const idToStreamMap = new Map(); streams.forEach(stream => { - sourceToIdMap.set(stream.obs_source_name, stream.id); + // Generate stream group name to match what's written to files + const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`; + sourceToIdMap.set(streamGroupName, stream.id); idToStreamMap.set(stream.id, stream); }); diff --git a/lib/security.ts b/lib/security.ts index e38cecf..d5bc705 100644 --- a/lib/security.ts +++ b/lib/security.ts @@ -38,7 +38,6 @@ export function sanitizeString(input: string, maxLength: number = 100): string { // Validation schemas export interface StreamInput { name: string; - obs_source_name: string; url: string; team_id: number; } @@ -58,11 +57,6 @@ export function validateStreamInput(input: unknown): { valid: boolean; errors: s errors.push('Name must be 100 characters or less'); } - if (!data.obs_source_name || typeof data.obs_source_name !== 'string') { - errors.push('OBS source name is required and must be a string'); - } else if (data.obs_source_name.length > 100) { - errors.push('OBS source name must be 100 characters or less'); - } if (!data.url || typeof data.url !== 'string') { errors.push('URL is required and must be a string'); @@ -83,7 +77,6 @@ export function validateStreamInput(input: unknown): { valid: boolean; errors: s errors: [], data: { name: sanitizeString(data.name as string), - obs_source_name: sanitizeString(data.obs_source_name as string), url: data.url as string, team_id: data.team_id as number, },