Merge pull request 'Auto-generate OBS source names and implement team-based stream organization' (#5) from auto-generate-obs-source-names into main
All checks were successful
Lint and Build / build (push) Successful in 2m48s
All checks were successful
Lint and Build / build (push) Successful in 2m48s
Reviewed-on: #5
This commit is contained in:
commit
931813964f
12 changed files with 382 additions and 99 deletions
|
@ -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) {
|
||||
|
|
|
@ -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});
|
||||
|
|
|
@ -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<Stream>(
|
||||
'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);
|
||||
|
|
|
@ -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<string, string>
|
||||
} {
|
||||
const errors: Record<string, string> = {};
|
||||
|
@ -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);
|
||||
|
|
30
app/page.tsx
30
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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manage Streams Section */}
|
||||
{streams.length > 0 && (
|
||||
<div className="glass p-6 mt-6">
|
||||
<h2 className="card-title">Manage Streams</h2>
|
||||
<div className="grid gap-4">
|
||||
{streams.map((stream) => (
|
||||
<div key={stream.id} className="glass p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{stream.name}</h3>
|
||||
<p className="text-sm text-white/60">{stream.obs_source_name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/edit/${stream.id}`}
|
||||
className="btn-secondary btn-sm"
|
||||
>
|
||||
<span className="icon">✏️</span>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
|
|
|
@ -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() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* OBS Source Name */}
|
||||
<div>
|
||||
<label className="block text-white font-semibold mb-3">
|
||||
OBS Source Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="obs_source_name"
|
||||
value={formData.obs_source_name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className={`input ${
|
||||
validationErrors.obs_source_name ? 'border-red-500/60 bg-red-500/10' : ''
|
||||
}`}
|
||||
placeholder="Enter the exact source name from OBS"
|
||||
/>
|
||||
{validationErrors.obs_source_name && (
|
||||
<div className="text-red-400 text-sm mt-2">
|
||||
{validationErrors.obs_source_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Twitch Username */}
|
||||
<div>
|
||||
|
@ -315,13 +289,21 @@ export default function AddStream() {
|
|||
{streams.map((stream) => {
|
||||
const team = teams.find(t => t.id === stream.team_id);
|
||||
return (
|
||||
<div key={stream.id} className="glass p-4">
|
||||
<div key={stream.id} className="glass p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="bg-gradient-to-br from-green-500 to-blue-600 rounded-lg flex items-center justify-center text-white font-bold flex-shrink-0"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
fontSize: '24px',
|
||||
marginRight: '16px'
|
||||
}}
|
||||
>
|
||||
{stream.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white">{stream.name}</div>
|
||||
<div className="text-sm text-white/60">OBS: {stream.obs_source_name}</div>
|
||||
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
||||
|
@ -329,12 +311,13 @@ export default function AddStream() {
|
|||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div className="flex justify-end">
|
||||
<a
|
||||
href={stream.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary text-sm"
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
|
|
1
files/ss_large.txt
Normal file
1
files/ss_large.txt
Normal file
|
@ -0,0 +1 @@
|
|||
wa_stream
|
0
files/ss_left.txt
Normal file
0
files/ss_left.txt
Normal file
0
files/ss_right.text
Normal file
0
files/ss_right.text
Normal file
255
lib/obsClient.js
255
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
|
||||
};
|
|
@ -43,7 +43,9 @@ export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_n
|
|||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue