Major enhancements to stream management and UI improvements #6
12 changed files with 600 additions and 74 deletions
46
CLAUDE.md
46
CLAUDE.md
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
This is a Next.js web application that controls multiple OBS Source Switchers. It provides a UI for managing stream sources across different screen layouts (large, left, right, topLeft, topRight, bottomLeft, bottomRight) and communicates with OBS WebSocket API to control streaming sources.
|
This is a Next.js web application (branded as "Live Stream Manager") that controls multiple OBS Source Switchers. It provides a UI for managing live stream sources across different screen layouts (large, left, right, topLeft, topRight, bottomLeft, bottomRight) and communicates with OBS WebSocket API to control streaming sources.
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
|
@ -42,6 +42,8 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
||||||
- `useToast.ts` - Toast notification system for user feedback
|
- `useToast.ts` - Toast notification system for user feedback
|
||||||
- `security.ts` - Input validation and sanitization utilities
|
- `security.ts` - Input validation and sanitization utilities
|
||||||
- `/types` - TypeScript type definitions
|
- `/types` - TypeScript type definitions
|
||||||
|
- `Stream`, `StreamWithTeam` - Stream data types with team relationships
|
||||||
|
- `Team` - Team data with group management fields
|
||||||
- `/files` - Default directory for SQLite database and text files (configurable via .env.local)
|
- `/files` - Default directory for SQLite database and text files (configurable via .env.local)
|
||||||
- `/scripts` - Database setup and management scripts
|
- `/scripts` - Database setup and management scripts
|
||||||
- `/.forgejo/workflows` - Forgejo CI/CD workflows for self-hosted runners
|
- `/.forgejo/workflows` - Forgejo CI/CD workflows for self-hosted runners
|
||||||
|
@ -85,17 +87,26 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
||||||
- `POST /api/addStream` - Add new stream to database and create browser source in OBS (accepts Twitch username, auto-generates URL)
|
- `POST /api/addStream` - Add new stream to database and create browser source in OBS (accepts Twitch username, auto-generates URL)
|
||||||
- `GET /api/streams` - Get all available streams
|
- `GET /api/streams` - Get all available streams
|
||||||
- `GET /api/streams/[id]` - Get individual stream details
|
- `GET /api/streams/[id]` - Get individual stream details
|
||||||
- `DELETE /api/streams/[id]` - Delete stream from both OBS and database with confirmation
|
- `DELETE /api/streams/[id]` - Delete stream with comprehensive OBS cleanup:
|
||||||
|
- Removes stream's nested scene
|
||||||
|
- Deletes browser source
|
||||||
|
- Removes from all source switchers
|
||||||
|
- Clears text files referencing the stream
|
||||||
|
|
||||||
#### Source Control
|
#### Source Control
|
||||||
- `POST /api/setActive` - Set active stream for specific screen position
|
- `POST /api/setActive` - Set active stream for specific screen position (writes team-prefixed stream name to text file)
|
||||||
- `GET /api/getActive` - Get currently active sources for all screens
|
- `GET /api/getActive` - Get currently active sources for all screens
|
||||||
|
|
||||||
#### Team Management
|
#### Team Management
|
||||||
- `GET /api/teams` - Get all teams with group information
|
- `GET /api/teams` - Get all teams with group information
|
||||||
- `POST /api/teams` - Create new team
|
- `POST /api/teams` - Create new team
|
||||||
- `PUT /api/teams/[id]` - Update team name, group_name, or group_uuid
|
- `PUT /api/teams/[id]` - Update team name, group_name, or group_uuid
|
||||||
- `DELETE /api/teams/[id]` - Delete team and associated streams
|
- `DELETE /api/teams/[teamId]` - Delete team with comprehensive OBS cleanup:
|
||||||
|
- Deletes team scene/group
|
||||||
|
- Removes team text source
|
||||||
|
- Deletes all associated stream scenes
|
||||||
|
- Removes all browser sources
|
||||||
|
- Clears all text files
|
||||||
- `GET /api/getTeamName` - Get team name by ID
|
- `GET /api/getTeamName` - Get team name by ID
|
||||||
- `POST /api/createGroup` - Create OBS group from team and store UUID
|
- `POST /api/createGroup` - Create OBS group from team and store UUID
|
||||||
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
|
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
|
||||||
|
@ -186,12 +197,22 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
|
||||||
|
|
||||||
### Stream Management
|
### Stream Management
|
||||||
- **Twitch Integration**: Simplified stream addition using just Twitch username (auto-generates full URL)
|
- **Twitch Integration**: Simplified stream addition using just Twitch username (auto-generates full URL)
|
||||||
- **Stream Deletion**: Safe deletion workflow with confirmation that removes from both OBS and database
|
- **Enhanced Stream Deletion**: Comprehensive cleanup that removes:
|
||||||
|
- Stream's nested scene from OBS
|
||||||
|
- Browser source and any references
|
||||||
|
- Entries from all source switchers
|
||||||
|
- Text files referencing the stream
|
||||||
|
- **Audio Control**: Browser sources created with "Control Audio via OBS" enabled and auto-muted
|
||||||
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
|
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
|
||||||
- **Team Association**: Streams can be organized under teams for better management
|
- **Team Association**: Streams organized under teams with proper naming conventions
|
||||||
|
|
||||||
### Team & Group Management
|
### Team & Group Management
|
||||||
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
|
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
|
||||||
|
- **Enhanced Team Deletion**: Comprehensive cleanup that removes:
|
||||||
|
- Team scene/group from OBS
|
||||||
|
- Shared team text source
|
||||||
|
- All associated stream scenes and sources
|
||||||
|
- All browser sources with team prefix
|
||||||
- **Sync Verification**: Real-time verification of database-OBS group synchronization
|
- **Sync Verification**: Real-time verification of database-OBS group synchronization
|
||||||
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
|
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
|
||||||
- **Visual Indicators**: Clear status indicators for group linking and sync problems
|
- **Visual Indicators**: Clear status indicators for group linking and sync problems
|
||||||
|
@ -213,3 +234,16 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
|
||||||
- **Migration Scripts**: Database migration tools for schema updates
|
- **Migration Scripts**: Database migration tools for schema updates
|
||||||
- **Security**: Input validation, sanitization, and secure API design
|
- **Security**: Input validation, sanitization, and secure API design
|
||||||
- **Testing**: Comprehensive error handling and edge case management
|
- **Testing**: Comprehensive error handling and edge case management
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### Text Centering Problem
|
||||||
|
- **Issue**: Team name text overlays are not properly centered horizontally in OBS
|
||||||
|
- **Current Behavior**: Text left edge positions at center point (960px) instead of text center
|
||||||
|
- **Attempted Solutions**:
|
||||||
|
- Various alignment properties (alignment: 5, boundsAlignment: 5)
|
||||||
|
- Manual position calculation based on text width
|
||||||
|
- Different bounds configurations
|
||||||
|
- Multiple transform approaches
|
||||||
|
- **Workaround**: Manually change "Positional Alignment" to "Center" in OBS UI
|
||||||
|
- **Status**: Unresolved - requires further investigation into OBS API behavior
|
71
README.md
71
README.md
|
@ -1,15 +1,19 @@
|
||||||
# OBS Source Switcher Plugin UI
|
# Live Stream Manager
|
||||||
|
|
||||||
A professional [Next.js](https://nextjs.org) web application for controlling multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) with real-time WebSocket integration and modern glass morphism UI.
|
A professional [Next.js](https://nextjs.org) web application for managing live streams and controlling multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) with real-time WebSocket integration and modern glass morphism UI.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners)
|
- **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners)
|
||||||
- **Real-time OBS Integration**: WebSocket connection with live status monitoring
|
- **Real-time OBS Integration**: WebSocket connection with live status monitoring
|
||||||
- **Team & Stream Management**: Organize streams by teams with full CRUD operations
|
- **Enhanced Stream Management**: Create, edit, and delete streams with comprehensive OBS cleanup
|
||||||
- **Modern UI**: Glass morphism design with responsive layout
|
- **Team Organization**: Organize streams by teams with full CRUD operations and scene synchronization
|
||||||
|
- **Comprehensive Deletion**: Remove streams/teams with complete OBS component cleanup (scenes, sources, text files)
|
||||||
|
- **Audio Control**: Browser sources created with muted audio and OBS control enabled
|
||||||
|
- **Modern UI**: Glass morphism design with responsive layout and accessibility features
|
||||||
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
|
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
|
||||||
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
|
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
|
||||||
|
- **UUID-based Tracking**: Robust OBS group synchronization with rename-safe tracking
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
@ -101,12 +105,59 @@ npm run type-check # TypeScript validation
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/streams` - List all streams
|
### Stream Management
|
||||||
- `POST /api/addStream` - Create new stream and OBS source
|
- `GET /api/streams` - List all streams with team information
|
||||||
- `POST /api/setActive` - Set active stream for screen position
|
- `GET /api/streams/[id]` - Get individual stream details
|
||||||
- `GET /api/obsStatus` - Real-time OBS connection status
|
- `POST /api/addStream` - Create new stream with browser source and team association
|
||||||
- `GET /api/teams` - Team management
|
- `PUT /api/streams/[id]` - Update stream information
|
||||||
|
- `DELETE /api/streams/[id]` - Delete stream with comprehensive OBS cleanup:
|
||||||
|
- Removes stream's nested scene
|
||||||
|
- Deletes browser source
|
||||||
|
- Removes from all source switchers
|
||||||
|
- Clears text files referencing the stream
|
||||||
|
|
||||||
See `CLAUDE.md` for detailed architecture documentation.
|
### Source Control
|
||||||
|
- `POST /api/setActive` - Set active stream for screen position (writes team-prefixed name to text file)
|
||||||
|
- `GET /api/getActive` - Get currently active sources for all screen positions
|
||||||
|
|
||||||
|
### Team Management
|
||||||
|
- `GET /api/teams` - Get all teams with group information and sync status
|
||||||
|
- `POST /api/teams` - Create new team with optional OBS scene creation
|
||||||
|
- `PUT /api/teams/[teamId]` - Update team name, group_name, or group_uuid
|
||||||
|
- `DELETE /api/teams/[teamId]` - Delete team with comprehensive OBS cleanup:
|
||||||
|
- Deletes team scene/group
|
||||||
|
- Removes team text source
|
||||||
|
- Deletes all associated stream scenes
|
||||||
|
- Removes all browser sources with team prefix
|
||||||
|
- Clears all related text files
|
||||||
|
- `GET /api/getTeamName` - Get team name by ID
|
||||||
|
|
||||||
|
### OBS Group/Scene Management
|
||||||
|
- `POST /api/createGroup` - Create OBS scene from team and store UUID
|
||||||
|
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
|
||||||
|
- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking
|
||||||
|
- Detects orphaned groups (excludes system scenes)
|
||||||
|
- Identifies name mismatches
|
||||||
|
- Shows sync status for all teams
|
||||||
|
|
||||||
|
### System Status
|
||||||
|
- `GET /api/obsStatus` - Real-time OBS connection, streaming, and recording status
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
All endpoints require API key authentication when `API_KEY` environment variable is set.
|
||||||
|
|
||||||
|
See `CLAUDE.md` for detailed architecture documentation and implementation details.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### Text Centering
|
||||||
|
- **Issue**: Team name text overlays position left edge at center instead of centering the text itself
|
||||||
|
- **Workaround**: Manually change "Positional Alignment" to "Center" in OBS UI
|
||||||
|
- **Status**: Under investigation - requires further research into OBS API behavior
|
||||||
|
|
||||||
|
### System Scene Exclusion
|
||||||
|
Infrastructure scenes containing source switchers are excluded from orphaned group detection:
|
||||||
|
- 1-Screen, 2-Screen, 4-Screen, Starting, Ending, Audio, Movies
|
||||||
|
- Additional scenes can be added to the `SYSTEM_SCENES` array in `/app/api/verifyGroups/route.ts`
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { FILE_DIRECTORY } from '../../../config';
|
import { FILE_DIRECTORY } from '../../../config';
|
||||||
import { getDatabase } from '../../../lib/database';
|
import { getDatabase } from '../../../lib/database';
|
||||||
import { Stream } from '@/types';
|
import { StreamWithTeam } from '@/types';
|
||||||
import { validateScreenInput } from '../../../lib/security';
|
import { validateScreenInput } from '../../../lib/security';
|
||||||
import { TABLE_NAMES } from '../../../lib/constants';
|
import { TABLE_NAMES } from '../../../lib/constants';
|
||||||
|
|
||||||
|
@ -27,8 +27,11 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const stream: Stream | undefined = await db.get<Stream>(
|
const stream: StreamWithTeam | undefined = await db.get<StreamWithTeam>(
|
||||||
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
`SELECT s.*, t.team_name, t.group_name
|
||||||
|
FROM ${TABLE_NAMES.STREAMS} s
|
||||||
|
LEFT JOIN ${TABLE_NAMES.TEAMS} t ON s.team_id = t.team_id
|
||||||
|
WHERE s.id = ?`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -38,8 +41,11 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Stream not found' }, { status: 400 });
|
return NextResponse.json({ error: 'Stream not found' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use stream group name instead of individual obs_source_name
|
// Construct proper stream group name with team prefix
|
||||||
const streamGroupName = `${stream.name.toLowerCase().replace(/\s+/g, '_')}_stream`;
|
const groupName = stream.group_name || stream.team_name;
|
||||||
|
const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const cleanStreamName = stream.name.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
|
||||||
fs.writeFileSync(filePath, streamGroupName);
|
fs.writeFileSync(filePath, streamGroupName);
|
||||||
return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 });
|
return NextResponse.json({ message: `${screen} updated successfully.` }, { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -1,16 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../lib/database';
|
import { getDatabase } from '../../../../lib/database';
|
||||||
import { TABLE_NAMES } from '../../../../lib/constants';
|
import { TABLE_NAMES } from '../../../../lib/constants';
|
||||||
import { getOBSClient } from '../../../../lib/obsClient';
|
import { deleteStreamComponents, clearTextFilesForStream } from '../../../../lib/obsClient';
|
||||||
|
|
||||||
interface OBSInput {
|
|
||||||
inputName: string;
|
|
||||||
inputUuid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetInputListResponse {
|
|
||||||
inputs: OBSInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET single stream
|
// GET single stream
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
@ -103,9 +94,12 @@ export async function DELETE(
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
// Check if stream exists
|
// Check if stream exists and get team info
|
||||||
const existingStream = await db.get(
|
const existingStream = await db.get(
|
||||||
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
`SELECT s.*, t.team_name, t.group_name
|
||||||
|
FROM ${TABLE_NAMES.STREAMS} s
|
||||||
|
LEFT JOIN ${TABLE_NAMES.TEAMS} t ON s.team_id = t.team_id
|
||||||
|
WHERE s.id = ?`,
|
||||||
[resolvedParams.id]
|
[resolvedParams.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -116,35 +110,38 @@ export async function DELETE(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to delete from OBS first
|
// Try comprehensive OBS cleanup first
|
||||||
|
let obsCleanupResults = null;
|
||||||
try {
|
try {
|
||||||
const obs = await getOBSClient();
|
if (existingStream.name && existingStream.team_name) {
|
||||||
console.log('OBS client obtained:', !!obs);
|
const groupName = existingStream.group_name || existingStream.team_name;
|
||||||
|
|
||||||
if (obs && existingStream.obs_source_name) {
|
console.log(`Starting comprehensive OBS cleanup for stream: ${existingStream.name}`);
|
||||||
console.log(`Attempting to remove OBS source: ${existingStream.obs_source_name}`);
|
console.log(`Team: ${existingStream.team_name}, Group: ${groupName}`);
|
||||||
|
|
||||||
// Get the input UUID first
|
// Perform comprehensive OBS deletion
|
||||||
const response = await obs.call('GetInputList');
|
obsCleanupResults = await deleteStreamComponents(
|
||||||
const inputs = response as GetInputListResponse;
|
existingStream.name,
|
||||||
console.log(`Found ${inputs.inputs.length} inputs in OBS`);
|
existingStream.team_name,
|
||||||
|
groupName
|
||||||
|
);
|
||||||
|
|
||||||
const input = inputs.inputs.find((i: OBSInput) => i.inputName === existingStream.obs_source_name);
|
console.log('OBS cleanup results:', obsCleanupResults);
|
||||||
|
|
||||||
|
// Clear text files that reference this stream
|
||||||
|
const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const cleanStreamName = existingStream.name.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
|
||||||
|
|
||||||
|
const textFileResults = await clearTextFilesForStream(streamGroupName);
|
||||||
|
console.log('Text file cleanup results:', textFileResults);
|
||||||
|
|
||||||
if (input) {
|
|
||||||
console.log(`Found input with UUID: ${input.inputUuid}`);
|
|
||||||
await obs.call('RemoveInput', { inputUuid: input.inputUuid });
|
|
||||||
console.log(`Successfully removed OBS source: ${existingStream.obs_source_name}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Input not found in OBS: ${existingStream.obs_source_name}`);
|
|
||||||
console.log('Available inputs:', inputs.inputs.map((i: OBSInput) => i.inputName));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log('OBS client not available or no source name provided');
|
console.log('Missing stream or team information for comprehensive cleanup');
|
||||||
}
|
}
|
||||||
} catch (obsError) {
|
} catch (obsError) {
|
||||||
console.error('Error removing source from OBS:', obsError);
|
console.error('Error during comprehensive OBS cleanup:', obsError);
|
||||||
// Continue with database deletion even if OBS removal fails
|
// Continue with database deletion even if OBS cleanup fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete stream from database
|
// Delete stream from database
|
||||||
|
@ -154,7 +151,8 @@ export async function DELETE(
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Stream deleted successfully'
|
message: 'Stream deleted successfully',
|
||||||
|
cleanup: obsCleanupResults || 'OBS cleanup was not performed'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting stream:', error);
|
console.error('Error deleting stream:', error);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '@/lib/database';
|
import { getDatabase } from '@/lib/database';
|
||||||
import { TABLE_NAMES } from '@/lib/constants';
|
import { TABLE_NAMES } from '@/lib/constants';
|
||||||
|
import { deleteTeamComponents, deleteStreamComponents, clearTextFilesForStream } from '@/lib/obsClient';
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -65,26 +66,78 @@ export async function DELETE(
|
||||||
const teamId = parseInt(teamIdParam);
|
const teamId = parseInt(teamIdParam);
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// First get the team and stream information before deletion
|
||||||
|
const team = await db.get(
|
||||||
|
`SELECT * FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
|
||||||
|
[teamId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all streams for this team
|
||||||
|
const streams = await db.all(
|
||||||
|
`SELECT * FROM ${TABLE_NAMES.STREAMS} WHERE team_id = ?`,
|
||||||
|
[teamId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Deleting team "${team.team_name}" with ${streams.length} streams`);
|
||||||
|
|
||||||
|
// Try to clean up OBS components first
|
||||||
|
let obsCleanupResults = null;
|
||||||
|
try {
|
||||||
|
// Delete each stream's OBS components
|
||||||
|
for (const stream of streams) {
|
||||||
|
try {
|
||||||
|
const groupName = team.group_name || team.team_name;
|
||||||
|
console.log(`Deleting OBS components for stream "${stream.name}"`);
|
||||||
|
|
||||||
|
// Delete stream components
|
||||||
|
await deleteStreamComponents(stream.name, team.team_name, groupName);
|
||||||
|
|
||||||
|
// Clear any text files that reference this stream
|
||||||
|
const cleanGroupName = groupName.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const cleanStreamName = stream.name.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const streamGroupName = `${cleanGroupName}_${cleanStreamName}_stream`;
|
||||||
|
await clearTextFilesForStream(streamGroupName);
|
||||||
|
} catch (streamError) {
|
||||||
|
console.error(`Error deleting stream "${stream.name}" OBS components:`, streamError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete team-level OBS components
|
||||||
|
obsCleanupResults = await deleteTeamComponents(team.team_name, team.group_name);
|
||||||
|
console.log('Team OBS cleanup results:', obsCleanupResults);
|
||||||
|
|
||||||
|
} catch (obsError) {
|
||||||
|
console.error('Error during OBS cleanup:', obsError);
|
||||||
|
// Continue with database deletion even if OBS cleanup fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now delete from database
|
||||||
await db.run('BEGIN TRANSACTION');
|
await db.run('BEGIN TRANSACTION');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Delete all streams for this team
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE team_id = ?`,
|
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE team_id = ?`,
|
||||||
[teamId]
|
[teamId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delete the team
|
||||||
const result = await db.run(
|
const result = await db.run(
|
||||||
`DELETE FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
|
`DELETE FROM ${TABLE_NAMES.TEAMS} WHERE team_id = ?`,
|
||||||
[teamId]
|
[teamId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
await db.run('ROLLBACK');
|
|
||||||
return NextResponse.json({ error: 'Team not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.run('COMMIT');
|
await db.run('COMMIT');
|
||||||
return NextResponse.json({ message: 'Team and associated streams deleted successfully' });
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Team and all associated components deleted successfully',
|
||||||
|
deletedStreams: streams.length,
|
||||||
|
obsCleanup: obsCleanupResults || 'OBS cleanup was not performed'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await db.run('ROLLBACK');
|
await db.run('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
@ -3,6 +3,18 @@ import { getDatabase } from '../../../lib/database';
|
||||||
import { TABLE_NAMES } from '../../../lib/constants';
|
import { TABLE_NAMES } from '../../../lib/constants';
|
||||||
import { getOBSClient } from '../../../lib/obsClient';
|
import { getOBSClient } from '../../../lib/obsClient';
|
||||||
|
|
||||||
|
// System scenes that should not be considered orphaned
|
||||||
|
// These are infrastructure scenes that contain source switchers or other system components
|
||||||
|
const SYSTEM_SCENES: string[] = [
|
||||||
|
'1-Screen',
|
||||||
|
'2-Screen',
|
||||||
|
'4-Screen',
|
||||||
|
'Starting',
|
||||||
|
'Ending',
|
||||||
|
'Audio',
|
||||||
|
'Movies'
|
||||||
|
];
|
||||||
|
|
||||||
interface OBSScene {
|
interface OBSScene {
|
||||||
sceneName: string;
|
sceneName: string;
|
||||||
sceneUuid: string;
|
sceneUuid: string;
|
||||||
|
@ -70,7 +82,8 @@ export async function GET() {
|
||||||
missing_in_obs: verification.filter(team => !team.exists_in_obs),
|
missing_in_obs: verification.filter(team => !team.exists_in_obs),
|
||||||
name_mismatches: verification.filter(team => team.name_changed),
|
name_mismatches: verification.filter(team => team.name_changed),
|
||||||
orphaned_in_obs: obsScenes.filter(scene =>
|
orphaned_in_obs: obsScenes.filter(scene =>
|
||||||
!teams.some(team => team.group_uuid === scene.sceneUuid || team.group_name === scene.sceneName)
|
!teams.some(team => team.group_uuid === scene.sceneUuid || team.group_name === scene.sceneName) &&
|
||||||
|
!SYSTEM_SCENES.includes(scene.sceneName)
|
||||||
).map(s => ({ name: s.sceneName, uuid: s.sceneUuid }))
|
).map(s => ({ name: s.sceneName, uuid: s.sceneUuid }))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,8 +5,8 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import PerformanceDashboard from '@/components/PerformanceDashboard';
|
import PerformanceDashboard from '@/components/PerformanceDashboard';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'OBS Source Switcher',
|
title: 'Live Stream Manager',
|
||||||
description: 'A tool to manage OBS sources dynamically',
|
description: 'A tool to manage live stream sources dynamically',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
|
@ -140,7 +140,7 @@ export default function Home() {
|
||||||
<div className="container section">
|
<div className="container section">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="title">Stream Control Center</h1>
|
<h1 className="title">Live Stream Control Center</h1>
|
||||||
<p className="subtitle">
|
<p className="subtitle">
|
||||||
Manage your OBS sources across multiple screen positions
|
Manage your OBS sources across multiple screen positions
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -141,7 +141,7 @@ export default function Footer() {
|
||||||
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="icon-sm" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>OBS Stream Manager</span>
|
<span>Live Stream Manager</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -20,7 +20,7 @@ export default function Header() {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">OBS Stream Manager</h1>
|
<h1 className="text-xl font-bold">Live Stream Manager</h1>
|
||||||
<p className="text-sm opacity-80">Professional Control</p>
|
<p className="text-sm opacity-80">Professional Control</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
376
lib/obsClient.js
376
lib/obsClient.js
|
@ -351,12 +351,34 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Created browser source "${sourceName}" in nested scene`);
|
console.log(`Created browser source "${sourceName}" in nested scene`);
|
||||||
|
|
||||||
|
// Mute the audio stream for the browser source
|
||||||
|
try {
|
||||||
|
await obsClient.call('SetInputMute', {
|
||||||
|
inputName: sourceName,
|
||||||
|
inputMuted: true
|
||||||
|
});
|
||||||
|
console.log(`Muted audio for browser source "${sourceName}"`);
|
||||||
|
} catch (muteError) {
|
||||||
|
console.error(`Failed to mute audio for "${sourceName}":`, muteError.message);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add existing source to nested scene
|
// Add existing source to nested scene
|
||||||
await obsClient.call('CreateSceneItem', {
|
await obsClient.call('CreateSceneItem', {
|
||||||
sceneName: streamGroupName,
|
sceneName: streamGroupName,
|
||||||
sourceName: sourceName
|
sourceName: sourceName
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure audio is muted for existing source too
|
||||||
|
try {
|
||||||
|
await obsClient.call('SetInputMute', {
|
||||||
|
inputName: sourceName,
|
||||||
|
inputMuted: true
|
||||||
|
});
|
||||||
|
console.log(`Ensured audio is muted for existing browser source "${sourceName}"`);
|
||||||
|
} catch (muteError) {
|
||||||
|
console.error(`Failed to mute audio for existing source "${sourceName}":`, muteError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add text source to nested scene
|
// Add text source to nested scene
|
||||||
|
@ -379,18 +401,83 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
||||||
// Position the sources properly in the nested scene
|
// Position the sources properly in the nested scene
|
||||||
if (browserSourceItem && textSourceItem) {
|
if (browserSourceItem && textSourceItem) {
|
||||||
try {
|
try {
|
||||||
// Position text overlay at top-left of the browser source
|
// Position text overlay at top, then center horizontally
|
||||||
await obsClient.call('SetSceneItemTransform', {
|
await obsClient.call('SetSceneItemTransform', {
|
||||||
sceneName: streamGroupName, // In the nested scene
|
sceneName: streamGroupName, // In the nested scene
|
||||||
sceneItemId: textSourceItem.sceneItemId,
|
sceneItemId: textSourceItem.sceneItemId,
|
||||||
sceneItemTransform: {
|
sceneItemTransform: {
|
||||||
positionX: 10,
|
positionX: 0, // Start at left
|
||||||
positionY: 10,
|
positionY: 10, // Keep at top
|
||||||
scaleX: 1.0,
|
scaleX: 1.0,
|
||||||
scaleY: 1.0
|
scaleY: 1.0,
|
||||||
|
alignment: 5 // Center alignment
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply center horizontally transform (like clicking "Center Horizontally" in OBS UI)
|
||||||
|
const { sceneItemTransform: currentTransform } = await obsClient.call('GetSceneItemTransform', {
|
||||||
|
sceneName: streamGroupName,
|
||||||
|
sceneItemId: textSourceItem.sceneItemId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Current text transform before centering:', JSON.stringify(currentTransform, null, 2));
|
||||||
|
|
||||||
|
// Get the actual scene dimensions
|
||||||
|
let sceneWidth = 1920; // Default assumption
|
||||||
|
let sceneHeight = 1080;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sceneInfo = await obsClient.call('GetSceneItemList', { sceneName: streamGroupName });
|
||||||
|
console.log(`Scene dimensions check for "${streamGroupName}":`, sceneInfo);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not get scene info:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual positioning: Calculate where to place text so its center is at canvas center
|
||||||
|
const canvasWidth = sceneWidth;
|
||||||
|
const canvasCenter = canvasWidth / 2;
|
||||||
|
const textWidth = currentTransform.width || currentTransform.sourceWidth || 0;
|
||||||
|
|
||||||
|
// Since we know the scene is bounded to 1600x900 from earlier logs, try that
|
||||||
|
const boundedWidth = 1600;
|
||||||
|
const boundedCenter = boundedWidth / 2; // 800
|
||||||
|
const alternatePosition = boundedCenter - (textWidth / 2);
|
||||||
|
|
||||||
|
console.log(`Manual centering calculation:`);
|
||||||
|
console.log(`- Scene/Canvas width: ${canvasWidth}`);
|
||||||
|
console.log(`- Canvas center: ${canvasCenter}`);
|
||||||
|
console.log(`- Text width: ${textWidth}`);
|
||||||
|
console.log(`- Position for 1920px canvas: ${canvasCenter - (textWidth / 2)}`);
|
||||||
|
console.log(`- Bounded scene width: ${boundedWidth}`);
|
||||||
|
console.log(`- Bounded center: ${boundedCenter}`);
|
||||||
|
console.log(`- Position for 1600px bounded scene: ${alternatePosition}`);
|
||||||
|
|
||||||
|
// Set the position with left alignment (0) for predictable positioning
|
||||||
|
await obsClient.call('SetSceneItemTransform', {
|
||||||
|
sceneName: streamGroupName,
|
||||||
|
sceneItemId: textSourceItem.sceneItemId,
|
||||||
|
sceneItemTransform: {
|
||||||
|
positionX: alternatePosition, // Use 1600px scene width calculation
|
||||||
|
positionY: 10, // Keep at top
|
||||||
|
alignment: 0, // Left alignment for predictable positioning
|
||||||
|
rotation: currentTransform.rotation || 0,
|
||||||
|
scaleX: currentTransform.scaleX || 1,
|
||||||
|
scaleY: currentTransform.scaleY || 1,
|
||||||
|
cropBottom: currentTransform.cropBottom || 0,
|
||||||
|
cropLeft: currentTransform.cropLeft || 0,
|
||||||
|
cropRight: currentTransform.cropRight || 0,
|
||||||
|
cropTop: currentTransform.cropTop || 0,
|
||||||
|
cropToBounds: currentTransform.cropToBounds || false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the final transform to verify
|
||||||
|
const { sceneItemTransform: finalTransform } = await obsClient.call('GetSceneItemTransform', {
|
||||||
|
sceneName: streamGroupName,
|
||||||
|
sceneItemId: textSourceItem.sceneItemId
|
||||||
|
});
|
||||||
|
console.log('Final text transform after centering:', JSON.stringify(finalTransform, null, 2));
|
||||||
|
|
||||||
console.log(`Stream sources positioned in nested scene "${streamGroupName}"`);
|
console.log(`Stream sources positioned in nested scene "${streamGroupName}"`);
|
||||||
} catch (positionError) {
|
} catch (positionError) {
|
||||||
console.error('Failed to position sources:', positionError.message || positionError);
|
console.error('Failed to position sources:', positionError.message || positionError);
|
||||||
|
@ -444,6 +531,281 @@ async function createStreamGroup(groupName, streamName, teamName, url) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comprehensive stream deletion function
|
||||||
|
async function deleteStreamComponents(streamName, teamName, groupName) {
|
||||||
|
try {
|
||||||
|
const obsClient = await getOBSClient();
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
console.log(`Starting comprehensive deletion for stream "${streamName}"`);
|
||||||
|
console.log(`Components to delete: scene="${streamGroupName}", source="${sourceName}"`);
|
||||||
|
|
||||||
|
// 1. Remove stream group scene item from team scene (if it exists)
|
||||||
|
try {
|
||||||
|
const { sceneItems: teamSceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
|
||||||
|
const streamGroupItem = teamSceneItems.find(item => item.sourceName === streamGroupName);
|
||||||
|
|
||||||
|
if (streamGroupItem) {
|
||||||
|
await obsClient.call('RemoveSceneItem', {
|
||||||
|
sceneName: groupName,
|
||||||
|
sceneItemId: streamGroupItem.sceneItemId
|
||||||
|
});
|
||||||
|
console.log(`Removed stream group "${streamGroupName}" from team scene "${groupName}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Team scene "${groupName}" not found or stream group not in it:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove the nested scene (stream group)
|
||||||
|
try {
|
||||||
|
await obsClient.call('RemoveScene', { sceneName: streamGroupName });
|
||||||
|
console.log(`Removed nested scene "${streamGroupName}"`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Nested scene "${streamGroupName}" not found:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove the browser source (if it's not used elsewhere)
|
||||||
|
try {
|
||||||
|
const { inputs } = await obsClient.call('GetInputList');
|
||||||
|
const browserSource = inputs.find(input => input.inputName === sourceName);
|
||||||
|
|
||||||
|
if (browserSource) {
|
||||||
|
await obsClient.call('RemoveInput', { inputUuid: browserSource.inputUuid });
|
||||||
|
console.log(`Removed browser source "${sourceName}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Browser source "${sourceName}" not found:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check if text source should be removed (only if no other streams from this team exist)
|
||||||
|
try {
|
||||||
|
// This would require checking if other streams from the same team exist
|
||||||
|
// For now, we'll leave the text source as it's shared across team streams
|
||||||
|
console.log(`Keeping shared text source "${textSourceName}" (shared across team streams)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error checking text source usage:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Remove from all source switchers
|
||||||
|
const screens = [
|
||||||
|
'ss_large',
|
||||||
|
'ss_left',
|
||||||
|
'ss_right',
|
||||||
|
'ss_top_left',
|
||||||
|
'ss_top_right',
|
||||||
|
'ss_bottom_left',
|
||||||
|
'ss_bottom_right'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const screen of screens) {
|
||||||
|
try {
|
||||||
|
await removeSourceFromSwitcher(screen, streamGroupName);
|
||||||
|
console.log(`Removed "${streamGroupName}" from ${screen}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error removing from ${screen}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Comprehensive deletion completed for stream "${streamName}"`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Stream components deleted successfully',
|
||||||
|
deletedComponents: {
|
||||||
|
streamGroupName,
|
||||||
|
sourceName,
|
||||||
|
removedFromSwitchers: screens.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in comprehensive stream deletion:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to remove source from source switcher
|
||||||
|
async function removeSourceFromSwitcher(switcherName, sourceName) {
|
||||||
|
try {
|
||||||
|
const obsClient = await getOBSClient();
|
||||||
|
|
||||||
|
// Get current source switcher options
|
||||||
|
const { inputSettings } = await obsClient.call('GetInputSettings', { inputName: switcherName });
|
||||||
|
const currentSources = inputSettings.sources || [];
|
||||||
|
|
||||||
|
// Filter out the source we want to remove
|
||||||
|
const updatedSources = currentSources.filter(source => source.value !== sourceName);
|
||||||
|
|
||||||
|
// Update the source switcher if changes were made
|
||||||
|
if (updatedSources.length !== currentSources.length) {
|
||||||
|
await obsClient.call('SetInputSettings', {
|
||||||
|
inputName: switcherName,
|
||||||
|
inputSettings: {
|
||||||
|
...inputSettings,
|
||||||
|
sources: updatedSources
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Removed "${sourceName}" from ${switcherName} (${currentSources.length - updatedSources.length} instances)`);
|
||||||
|
} else {
|
||||||
|
console.log(`Source "${sourceName}" not found in ${switcherName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing source from ${switcherName}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to clear text files that reference the deleted stream
|
||||||
|
async function clearTextFilesForStream(streamGroupName) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
||||||
|
const screens = [
|
||||||
|
'large',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'topLeft',
|
||||||
|
'topRight',
|
||||||
|
'bottomLeft',
|
||||||
|
'bottomRight'
|
||||||
|
];
|
||||||
|
|
||||||
|
let clearedFiles = [];
|
||||||
|
|
||||||
|
for (const screen of screens) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(FILE_DIRECTORY, `${screen}.txt`);
|
||||||
|
|
||||||
|
// Check if file exists and read its content
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const currentContent = fs.readFileSync(filePath, 'utf8').trim();
|
||||||
|
|
||||||
|
// If the file contains the stream group name we're deleting, clear it
|
||||||
|
if (currentContent === streamGroupName) {
|
||||||
|
fs.writeFileSync(filePath, '');
|
||||||
|
clearedFiles.push(screen);
|
||||||
|
console.log(`Cleared ${screen}.txt (was referencing deleted stream "${streamGroupName}")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error checking/clearing ${screen}.txt:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clearedFiles,
|
||||||
|
message: `Cleared ${clearedFiles.length} text files that referenced the deleted stream`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing text files:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Comprehensive team deletion function
|
||||||
|
async function deleteTeamComponents(teamName, groupName) {
|
||||||
|
try {
|
||||||
|
const obsClient = await getOBSClient();
|
||||||
|
|
||||||
|
console.log(`Starting comprehensive deletion for team "${teamName}"`);
|
||||||
|
|
||||||
|
// 1. Delete the team scene (group)
|
||||||
|
if (groupName) {
|
||||||
|
try {
|
||||||
|
await obsClient.call('RemoveScene', { sceneName: groupName });
|
||||||
|
console.log(`Removed team scene "${groupName}"`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Team scene "${groupName}" not found or already deleted:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete the team text source (shared across all team streams)
|
||||||
|
const textSourceName = teamName.toLowerCase().replace(/\s+/g, '_') + '_text';
|
||||||
|
try {
|
||||||
|
const { inputs } = await obsClient.call('GetInputList');
|
||||||
|
const textSource = inputs.find(input => input.inputName === textSourceName);
|
||||||
|
|
||||||
|
if (textSource) {
|
||||||
|
await obsClient.call('RemoveInput', { inputUuid: textSource.inputUuid });
|
||||||
|
console.log(`Removed team text source "${textSourceName}"`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Text source "${textSourceName}" not found:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get all scenes to check for nested stream scenes
|
||||||
|
try {
|
||||||
|
const { scenes } = await obsClient.call('GetSceneList');
|
||||||
|
const cleanGroupName = (groupName || teamName).toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
// Find all nested stream scenes for this team
|
||||||
|
const streamScenes = scenes.filter(scene =>
|
||||||
|
scene.sceneName.startsWith(`${cleanGroupName}_`) &&
|
||||||
|
scene.sceneName.endsWith('_stream')
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${streamScenes.length} stream scenes to delete`);
|
||||||
|
|
||||||
|
// Delete each stream scene
|
||||||
|
for (const streamScene of streamScenes) {
|
||||||
|
try {
|
||||||
|
await obsClient.call('RemoveScene', { sceneName: streamScene.sceneName });
|
||||||
|
console.log(`Removed stream scene "${streamScene.sceneName}"`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error removing stream scene "${streamScene.sceneName}":`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error finding stream scenes:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Remove any browser sources associated with this team
|
||||||
|
try {
|
||||||
|
const { inputs } = await obsClient.call('GetInputList');
|
||||||
|
const cleanGroupName = (groupName || teamName).toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
// Find all browser sources for this team
|
||||||
|
const teamBrowserSources = inputs.filter(input =>
|
||||||
|
input.inputKind === 'browser_source' &&
|
||||||
|
input.inputName.startsWith(`${cleanGroupName}_`)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${teamBrowserSources.length} browser sources to delete`);
|
||||||
|
|
||||||
|
// Delete each browser source
|
||||||
|
for (const source of teamBrowserSources) {
|
||||||
|
try {
|
||||||
|
await obsClient.call('RemoveInput', { inputUuid: source.inputUuid });
|
||||||
|
console.log(`Removed browser source "${source.inputName}"`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error removing browser source "${source.inputName}":`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error finding browser sources:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Comprehensive team deletion completed for "${teamName}"`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Team components deleted successfully',
|
||||||
|
deletedComponents: {
|
||||||
|
teamScene: groupName,
|
||||||
|
textSource: textSourceName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in comprehensive team deletion:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Export all functions
|
// Export all functions
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
@ -457,5 +819,9 @@ module.exports = {
|
||||||
addSourceToGroup,
|
addSourceToGroup,
|
||||||
createTextSource,
|
createTextSource,
|
||||||
createStreamGroup,
|
createStreamGroup,
|
||||||
getAvailableTextInputKind
|
getAvailableTextInputKind,
|
||||||
|
deleteStreamComponents,
|
||||||
|
removeSourceFromSwitcher,
|
||||||
|
clearTextFilesForStream,
|
||||||
|
deleteTeamComponents
|
||||||
};
|
};
|
|
@ -6,6 +6,11 @@ export type Stream = {
|
||||||
team_id: number;
|
team_id: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StreamWithTeam = Stream & {
|
||||||
|
team_name: string;
|
||||||
|
group_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Screen = {
|
export type Screen = {
|
||||||
screen: string;
|
screen: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue