Compare commits
No commits in common. "92c05303bd9e9529aabe7224bca4b955d0e3bbaf" and "5577730a948f1c6230f5ad3b750684cc3f971376" have entirely different histories.
92c05303bd
...
5577730a94
32 changed files with 300 additions and 1919 deletions
|
@ -9,12 +9,21 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
# Note: Node.js is pre-installed on self-hosted runners
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [ 20, 22 ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Clean NextJS cache
|
- name: Clean NextJS cache
|
||||||
run: rm -rf .next
|
run: rm -rf .next
|
||||||
|
|
||||||
|
@ -31,7 +40,8 @@ jobs:
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Upload Build Artifact
|
- name: Upload Build Artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: obs-ss-build
|
name: obs-ss-${{ matrix.node-version }}
|
||||||
|
include-hidden-files: 'true'
|
||||||
path: ./.next/*
|
path: ./.next/*
|
16
CLAUDE.md
16
CLAUDE.md
|
@ -84,8 +84,6 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
||||||
#### Team Management
|
#### Team Management
|
||||||
- `GET /api/teams` - Get all teams
|
- `GET /api/teams` - Get all teams
|
||||||
- `GET /api/getTeamName` - Get team name by ID
|
- `GET /api/getTeamName` - Get team name by ID
|
||||||
- `POST /api/createGroup` - Create OBS group from team
|
|
||||||
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
|
|
||||||
|
|
||||||
#### System Status
|
#### System Status
|
||||||
- `GET /api/obsStatus` - Real-time OBS connection and streaming status
|
- `GET /api/obsStatus` - Real-time OBS connection and streaming status
|
||||||
|
@ -94,7 +92,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
||||||
|
|
||||||
Dynamic table names with seasonal configuration:
|
Dynamic table names with seasonal configuration:
|
||||||
- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id
|
- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id
|
||||||
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name
|
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name
|
||||||
|
|
||||||
### OBS Integration Pattern
|
### OBS Integration Pattern
|
||||||
|
|
||||||
|
@ -102,18 +100,6 @@ The app uses a sophisticated dual integration approach:
|
||||||
|
|
||||||
1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
|
1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
|
||||||
2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors
|
2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors
|
||||||
3. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management
|
|
||||||
|
|
||||||
**Required OBS Source Switchers** (must be created with these exact names):
|
|
||||||
- `ss_large` - Large screen source switcher
|
|
||||||
- `ss_left` - Left screen source switcher
|
|
||||||
- `ss_right` - Right screen source switcher
|
|
||||||
- `ss_top_left` - Top left screen source switcher
|
|
||||||
- `ss_top_right` - Top right screen source switcher
|
|
||||||
- `ss_bottom_left` - Bottom left screen source switcher
|
|
||||||
- `ss_bottom_right` - Bottom right screen source switcher
|
|
||||||
|
|
||||||
See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructions.
|
|
||||||
|
|
||||||
**Source Control Workflow**:
|
**Source Control Workflow**:
|
||||||
1. User selects stream in React UI
|
1. User selects stream in React UI
|
||||||
|
|
|
@ -6,7 +6,7 @@ jest.mock('@/lib/database', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('/api/streams', () => {
|
describe('/api/streams', () => {
|
||||||
let mockDb: { all: jest.Mock };
|
let mockDb: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create mock database
|
// Create mock database
|
||||||
|
@ -27,7 +27,7 @@ describe('/api/streams', () => {
|
||||||
|
|
||||||
mockDb.all.mockResolvedValue(mockStreams);
|
mockDb.all.mockResolvedValue(mockStreams);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
expect(mockDb.all).toHaveBeenCalledWith(
|
expect(mockDb.all).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('SELECT * FROM')
|
expect.stringContaining('SELECT * FROM')
|
||||||
|
@ -40,7 +40,7 @@ describe('/api/streams', () => {
|
||||||
it('returns empty array when no streams exist', async () => {
|
it('returns empty array when no streams exist', async () => {
|
||||||
mockDb.all.mockResolvedValue([]);
|
mockDb.all.mockResolvedValue([]);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { NextResponse } = require('next/server');
|
const { NextResponse } = require('next/server');
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith([]);
|
expect(NextResponse.json).toHaveBeenCalledWith([]);
|
||||||
|
@ -50,7 +50,7 @@ describe('/api/streams', () => {
|
||||||
const dbError = new Error('Database connection failed');
|
const dbError = new Error('Database connection failed');
|
||||||
mockDb.all.mockRejectedValue(dbError);
|
mockDb.all.mockRejectedValue(dbError);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { NextResponse } = require('next/server');
|
const { NextResponse } = require('next/server');
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
|
@ -64,7 +64,7 @@ describe('/api/streams', () => {
|
||||||
const { getDatabase } = require('@/lib/database');
|
const { getDatabase } = require('@/lib/database');
|
||||||
getDatabase.mockRejectedValue(connectionError);
|
getDatabase.mockRejectedValue(connectionError);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { NextResponse } = require('next/server');
|
const { NextResponse } = require('next/server');
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
|
|
|
@ -24,7 +24,7 @@ jest.mock('@/lib/apiHelpers', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('/api/teams', () => {
|
describe('/api/teams', () => {
|
||||||
let mockDb: { all: jest.Mock };
|
let mockDb: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create mock database
|
// Create mock database
|
||||||
|
@ -46,7 +46,7 @@ describe('/api/teams', () => {
|
||||||
|
|
||||||
mockDb.all.mockResolvedValue(mockTeams);
|
mockDb.all.mockResolvedValue(mockTeams);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
expect(mockDb.all).toHaveBeenCalledWith(
|
expect(mockDb.all).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('SELECT * FROM')
|
expect.stringContaining('SELECT * FROM')
|
||||||
|
@ -59,7 +59,7 @@ describe('/api/teams', () => {
|
||||||
it('returns empty array when no teams exist', async () => {
|
it('returns empty array when no teams exist', async () => {
|
||||||
mockDb.all.mockResolvedValue([]);
|
mockDb.all.mockResolvedValue([]);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { createSuccessResponse } = require('@/lib/apiHelpers');
|
const { createSuccessResponse } = require('@/lib/apiHelpers');
|
||||||
expect(createSuccessResponse).toHaveBeenCalledWith([]);
|
expect(createSuccessResponse).toHaveBeenCalledWith([]);
|
||||||
|
@ -69,7 +69,7 @@ describe('/api/teams', () => {
|
||||||
const dbError = new Error('Table does not exist');
|
const dbError = new Error('Table does not exist');
|
||||||
mockDb.all.mockRejectedValue(dbError);
|
mockDb.all.mockRejectedValue(dbError);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError);
|
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError);
|
||||||
|
@ -80,7 +80,7 @@ describe('/api/teams', () => {
|
||||||
const { getDatabase } = require('@/lib/database');
|
const { getDatabase } = require('@/lib/database');
|
||||||
getDatabase.mockRejectedValue(connectionError);
|
getDatabase.mockRejectedValue(connectionError);
|
||||||
|
|
||||||
const _response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError);
|
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError);
|
||||||
|
|
|
@ -1,19 +1,24 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../lib/database';
|
import { getDatabase } from '../../../lib/database';
|
||||||
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup } from '../../../lib/obsClient';
|
import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher } from '../../../lib/obsClient';
|
||||||
import { open } from 'sqlite';
|
|
||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import path from 'path';
|
|
||||||
import { getTableName, BASE_TABLE_NAMES } from '../../../lib/constants';
|
|
||||||
|
|
||||||
interface OBSClient {
|
interface OBSClient {
|
||||||
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
call: (method: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OBSScene {
|
||||||
|
sceneName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface OBSInput {
|
interface OBSInput {
|
||||||
inputName: string;
|
inputName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetSceneListResponse {
|
||||||
|
currentProgramSceneName: string;
|
||||||
|
currentPreviewSceneName: string;
|
||||||
|
scenes: OBSScene[];
|
||||||
|
}
|
||||||
|
|
||||||
interface GetInputListResponse {
|
interface GetInputListResponse {
|
||||||
inputs: OBSInput[];
|
inputs: OBSInput[];
|
||||||
|
@ -28,38 +33,85 @@ const screens = [
|
||||||
'ss_bottom_right',
|
'ss_bottom_right',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchTeamInfo(teamId: number) {
|
async function fetchTeamName(teamId: number) {
|
||||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
|
||||||
try {
|
try {
|
||||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
|
||||||
const db = await open({
|
const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`);
|
||||||
filename: dbPath,
|
if (!response.ok) {
|
||||||
driver: sqlite3.Database,
|
throw new Error('Failed to fetch team name');
|
||||||
});
|
|
||||||
|
|
||||||
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
|
||||||
year: 2025,
|
|
||||||
season: 'summer',
|
|
||||||
suffix: 'sat'
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamInfo = await db.get(
|
|
||||||
`SELECT team_name, group_name FROM ${teamsTableName} WHERE team_id = ?`,
|
|
||||||
[teamId]
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.close();
|
|
||||||
return teamInfo;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error('Error fetching team info:', error.message);
|
|
||||||
} else {
|
|
||||||
console.error('An unknown error occurred:', error);
|
|
||||||
}
|
}
|
||||||
return null;
|
const data = await response.json();
|
||||||
}
|
return data.team_name;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
} else {
|
||||||
|
console.error('An unknown error occurred:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addBrowserSourceWithAudioControl(obs: OBSClient, sceneName: string, inputName: string, url: string) {
|
||||||
|
try {
|
||||||
|
// Step 1: Create the browser source input
|
||||||
|
await obs.call('CreateInput', {
|
||||||
|
sceneName,
|
||||||
|
inputName,
|
||||||
|
inputKind: 'browser_source',
|
||||||
|
inputSettings: {
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Browser source "${inputName}" created successfully.`);
|
||||||
|
|
||||||
|
// Step 2: Wait for the input to initialize
|
||||||
|
let inputReady = false;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
try {
|
||||||
|
await obs.call('GetInputSettings', { inputName });
|
||||||
|
inputReady = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
console.log(`Waiting for input "${inputName}" to initialize...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500)); // Wait 500ms before retrying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputReady) {
|
||||||
|
throw new Error(`Input "${inputName}" did not initialize in time.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Step 3: Enable "Reroute audio"
|
||||||
|
await obs.call('SetInputSettings', {
|
||||||
|
inputName,
|
||||||
|
inputSettings: {
|
||||||
|
reroute_audio: true,
|
||||||
|
},
|
||||||
|
overlay: true, // Keep existing settings and apply changes
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Audio rerouted for "${inputName}".`);
|
||||||
|
|
||||||
|
// Step 4: Mute the input
|
||||||
|
await obs.call('SetInputMute', {
|
||||||
|
inputName,
|
||||||
|
inputMuted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Audio muted for "${inputName}".`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error adding browser source with audio control:', error.message);
|
||||||
|
} else {
|
||||||
|
console.error('An unknown error occurred while adding browser source:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import { validateStreamInput } from '../../../lib/security';
|
import { validateStreamInput } from '../../../lib/security';
|
||||||
|
|
||||||
|
@ -108,23 +160,20 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
throw new Error('GetInputList failed.');
|
throw new Error('GetInputList failed.');
|
||||||
}
|
}
|
||||||
|
const teamName = await fetchTeamName(team_id);
|
||||||
const teamInfo = await fetchTeamInfo(team_id);
|
console.log('Team Name:', teamName)
|
||||||
if (!teamInfo) {
|
const response = await obs.call('GetSceneList');
|
||||||
throw new Error('Team not found');
|
const sceneListResponse = response as unknown as GetSceneListResponse;
|
||||||
|
const { scenes } = sceneListResponse;
|
||||||
|
const groupExists = scenes.some((scene: OBSScene) => scene.sceneName === teamName);
|
||||||
|
if (!groupExists) {
|
||||||
|
await obs.call('CreateScene', { sceneName: teamName });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Team Info:', teamInfo);
|
|
||||||
|
|
||||||
// Use group_name if it exists, otherwise use team_name
|
|
||||||
const groupName = teamInfo.group_name || teamInfo.team_name;
|
|
||||||
|
|
||||||
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
|
const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name);
|
||||||
|
|
||||||
if (!sourceExists) {
|
if (!sourceExists) {
|
||||||
// Create/ensure group exists and add source to it
|
await addBrowserSourceWithAudioControl(obs, teamName, obs_source_name, url)
|
||||||
await createGroupIfNotExists(groupName);
|
|
||||||
await addSourceToGroup(groupName, obs_source_name, url);
|
|
||||||
|
|
||||||
console.log(`OBS source "${obs_source_name}" created.`);
|
console.log(`OBS source "${obs_source_name}" created.`);
|
||||||
|
|
||||||
|
@ -147,12 +196,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const streamsTableName = getTableName(BASE_TABLE_NAMES.STREAMS, {
|
const query = `INSERT INTO streams_2025_spring_adr (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`;
|
||||||
year: 2025,
|
|
||||||
season: 'summer',
|
|
||||||
suffix: 'sat'
|
|
||||||
});
|
|
||||||
const query = `INSERT INTO ${streamsTableName} (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`;
|
|
||||||
db.run(query, [name, obs_source_name, url, team_id])
|
db.run(query, [name, obs_source_name, url, team_id])
|
||||||
await disconnectFromOBS();
|
await disconnectFromOBS();
|
||||||
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
|
return NextResponse.json({ message: 'Stream added successfully' }, {status: 201})
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { open } from 'sqlite';
|
|
||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import path from 'path';
|
|
||||||
import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants';
|
|
||||||
import { validateInteger } from '@/lib/security';
|
|
||||||
|
|
||||||
const { createGroupIfNotExists } = require('@/lib/obsClient');
|
|
||||||
|
|
||||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { teamId, groupName } = body;
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (!teamId || !groupName) {
|
|
||||||
return NextResponse.json({ error: 'Team ID and group name are required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const validTeamId = validateInteger(teamId);
|
|
||||||
if (!validTeamId) {
|
|
||||||
return NextResponse.json({ error: 'Invalid team ID' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize group name (only allow alphanumeric, spaces, dashes, underscores)
|
|
||||||
const sanitizedGroupName = groupName.replace(/[^a-zA-Z0-9\s\-_]/g, '');
|
|
||||||
if (!sanitizedGroupName || sanitizedGroupName.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'Invalid group name' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open database connection
|
|
||||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
|
||||||
const db = await open({
|
|
||||||
filename: dbPath,
|
|
||||||
driver: sqlite3.Database,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
|
||||||
year: 2025,
|
|
||||||
season: 'summer',
|
|
||||||
suffix: 'sat'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update team with group name
|
|
||||||
await db.run(
|
|
||||||
`UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`,
|
|
||||||
[sanitizedGroupName, validTeamId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create group in OBS
|
|
||||||
const result = await createGroupIfNotExists(sanitizedGroupName);
|
|
||||||
|
|
||||||
await db.close();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Group created/updated successfully',
|
|
||||||
groupName: sanitizedGroupName,
|
|
||||||
obsResult: result
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating group:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to create group' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +1,6 @@
|
||||||
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';
|
|
||||||
|
|
||||||
interface OBSInput {
|
|
||||||
inputName: string;
|
|
||||||
inputUuid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetInputListResponse {
|
|
||||||
inputs: OBSInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET single stream
|
// GET single stream
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
@ -116,38 +106,7 @@ export async function DELETE(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to delete from OBS first
|
// Delete stream
|
||||||
try {
|
|
||||||
const obs = await getOBSClient();
|
|
||||||
console.log('OBS client obtained:', !!obs);
|
|
||||||
|
|
||||||
if (obs && existingStream.obs_source_name) {
|
|
||||||
console.log(`Attempting to remove OBS source: ${existingStream.obs_source_name}`);
|
|
||||||
|
|
||||||
// Get the input UUID first
|
|
||||||
const response = await obs.call('GetInputList');
|
|
||||||
const inputs = response as GetInputListResponse;
|
|
||||||
console.log(`Found ${inputs.inputs.length} inputs in OBS`);
|
|
||||||
|
|
||||||
const input = inputs.inputs.find((i: OBSInput) => i.inputName === existingStream.obs_source_name);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
console.log('OBS client not available or no source name provided');
|
|
||||||
}
|
|
||||||
} catch (obsError) {
|
|
||||||
console.error('Error removing source from OBS:', obsError);
|
|
||||||
// Continue with database deletion even if OBS removal fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete stream from database
|
|
||||||
await db.run(
|
await db.run(
|
||||||
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
`DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`,
|
||||||
[resolvedParams.id]
|
[resolvedParams.id]
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { open } from 'sqlite';
|
|
||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import path from 'path';
|
|
||||||
import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants';
|
|
||||||
|
|
||||||
const { createGroupIfNotExists } = require('@/lib/obsClient');
|
|
||||||
|
|
||||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
// Open database connection
|
|
||||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
|
||||||
const db = await open({
|
|
||||||
filename: dbPath,
|
|
||||||
driver: sqlite3.Database,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
|
||||||
year: 2025,
|
|
||||||
season: 'summer',
|
|
||||||
suffix: 'sat'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all teams without groups
|
|
||||||
const teamsWithoutGroups = await db.all(
|
|
||||||
`SELECT team_id, team_name FROM ${teamsTableName} WHERE group_name IS NULL`
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncResults = [];
|
|
||||||
|
|
||||||
for (const team of teamsWithoutGroups) {
|
|
||||||
try {
|
|
||||||
// Create group in OBS using team name
|
|
||||||
const obsResult = await createGroupIfNotExists(team.team_name);
|
|
||||||
|
|
||||||
// Update database with group name
|
|
||||||
await db.run(
|
|
||||||
`UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`,
|
|
||||||
[team.team_name, team.team_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
syncResults.push({
|
|
||||||
teamId: team.team_id,
|
|
||||||
teamName: team.team_name,
|
|
||||||
groupName: team.team_name,
|
|
||||||
success: true,
|
|
||||||
obsResult
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error syncing team ${team.team_id}:`, error);
|
|
||||||
syncResults.push({
|
|
||||||
teamId: team.team_id,
|
|
||||||
teamName: team.team_name,
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.close();
|
|
||||||
|
|
||||||
const successCount = syncResults.filter(r => r.success).length;
|
|
||||||
const failureCount = syncResults.filter(r => !r.success).length;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Sync completed: ${successCount} successful, ${failureCount} failed`,
|
|
||||||
results: syncResults,
|
|
||||||
summary: {
|
|
||||||
total: syncResults.length,
|
|
||||||
successful: successCount,
|
|
||||||
failed: failureCount
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing groups:', error);
|
|
||||||
return NextResponse.json({ error: 'Failed to sync groups' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../lib/database';
|
import { getDatabase } from '../../../lib/database';
|
||||||
import { Team } from '@/types';
|
import { Team } from '@/types';
|
||||||
import { TABLE_NAMES } from '@/lib/constants';
|
import { TABLE_NAMES } from '@/lib/constants';
|
||||||
|
@ -38,14 +39,14 @@ function validateTeamInput(data: unknown): {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
data: { team_name: (team_name as string).trim() }
|
data: { team_name: team_name.trim() }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = withErrorHandling(async () => {
|
export const GET = withErrorHandling(async () => {
|
||||||
try {
|
try {
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`);
|
const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`);
|
||||||
|
|
||||||
return createSuccessResponse(teams);
|
return createSuccessResponse(teams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -85,8 +86,7 @@ export const POST = withErrorHandling(async (request: Request) => {
|
||||||
|
|
||||||
const newTeam: Team = {
|
const newTeam: Team = {
|
||||||
team_id: result.lastID!,
|
team_id: result.lastID!,
|
||||||
team_name: team_name,
|
team_name: team_name
|
||||||
group_name: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return createSuccessResponse(newTeam, 201);
|
return createSuccessResponse(newTeam, 201);
|
||||||
|
|
|
@ -86,7 +86,7 @@ export default function EditStream() {
|
||||||
if (streamId) {
|
if (streamId) {
|
||||||
fetchData();
|
fetchData();
|
||||||
}
|
}
|
||||||
}, [streamId, showError]);
|
}, [streamId]);
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
|
@ -213,10 +213,6 @@ body {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
z-index: 99999 !important;
|
|
||||||
position: absolute;
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default function Home() {
|
||||||
const activeSourceIds = useActiveSourceLookup(streams, activeSources);
|
const activeSourceIds = useActiveSourceLookup(streams, activeSources);
|
||||||
|
|
||||||
// Debounced API calls to prevent excessive requests
|
// Debounced API calls to prevent excessive requests
|
||||||
const setActiveFunction = useCallback(async (screen: ScreenType, id: number | null) => {
|
const debouncedSetActive = useDebounce(async (screen: ScreenType, id: number | null) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const selectedStream = streams.find(stream => stream.id === id);
|
const selectedStream = streams.find(stream => stream.id === id);
|
||||||
try {
|
try {
|
||||||
|
@ -62,9 +62,7 @@ export default function Home() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [streams, showError, showSuccess]);
|
}, 300);
|
||||||
|
|
||||||
const debouncedSetActive = useDebounce(setActiveFunction, 300);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Dropdown from '@/components/Dropdown';
|
import Dropdown from '@/components/Dropdown';
|
||||||
import { Team } from '@/types';
|
import { Team } from '@/types';
|
||||||
import { useToast } from '@/lib/useToast';
|
import { useToast } from '@/lib/useToast';
|
||||||
|
@ -18,7 +18,7 @@ export default function AddStream() {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
obs_source_name: '',
|
obs_source_name: '',
|
||||||
twitch_username: '',
|
url: '',
|
||||||
team_id: null,
|
team_id: null,
|
||||||
});
|
});
|
||||||
const [teams, setTeams] = useState<{id: number; name: string}[]>([]);
|
const [teams, setTeams] = useState<{id: number; name: string}[]>([]);
|
||||||
|
@ -26,11 +26,14 @@ export default function AddStream() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({});
|
const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({});
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{id: number; name: string} | null>(null);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const { toasts, removeToast, showSuccess, showError } = useToast();
|
const { toasts, removeToast, showSuccess, showError } = useToast();
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
// Fetch teams and streams on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [teamsResponse, streamsResponse] = await Promise.all([
|
const [teamsResponse, streamsResponse] = await Promise.all([
|
||||||
|
@ -60,13 +63,7 @@ export default function AddStream() {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [showError]);
|
};
|
||||||
|
|
||||||
// Fetch teams and streams on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
@ -88,32 +85,6 @@ export default function AddStream() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteConfirm) return;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/streams/${deleteConfirm.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
showSuccess('Stream Deleted', `"${deleteConfirm.name}" has been deleted successfully`);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
// Refetch the streams list
|
|
||||||
await fetchData();
|
|
||||||
} else {
|
|
||||||
showError('Failed to Delete Stream', data.error || 'Unknown error occurred');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting stream:', error);
|
|
||||||
showError('Failed to Delete Stream', 'Network error or server unavailable');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -129,10 +100,14 @@ export default function AddStream() {
|
||||||
errors.obs_source_name = 'OBS source name is required';
|
errors.obs_source_name = 'OBS source name is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.twitch_username.trim()) {
|
if (!formData.url.trim()) {
|
||||||
errors.twitch_username = 'Twitch username is required';
|
errors.url = 'Stream URL is required';
|
||||||
} else if (!/^[a-zA-Z0-9_]{4,25}$/.test(formData.twitch_username.trim())) {
|
} else {
|
||||||
errors.twitch_username = 'Twitch username must be 4-25 characters and contain only letters, numbers, and underscores';
|
try {
|
||||||
|
new URL(formData.url);
|
||||||
|
} catch {
|
||||||
|
errors.url = 'Please enter a valid URL';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.team_id) {
|
if (!formData.team_id) {
|
||||||
|
@ -148,21 +123,16 @@ export default function AddStream() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const submissionData = {
|
|
||||||
...formData,
|
|
||||||
url: `https://www.twitch.tv/${formData.twitch_username.trim()}`
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/api/addStream', {
|
const response = await fetch('/api/addStream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(submissionData),
|
body: JSON.stringify(formData),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showSuccess('Stream Added', `"${formData.name}" has been added successfully`);
|
showSuccess('Stream Added', `"${formData.name}" has been added successfully`);
|
||||||
setFormData({ name: '', obs_source_name: '', twitch_username: '', team_id: null });
|
setFormData({ name: '', obs_source_name: '', url: '', team_id: null });
|
||||||
setValidationErrors({});
|
setValidationErrors({});
|
||||||
fetchData();
|
fetchData();
|
||||||
} else {
|
} else {
|
||||||
|
@ -177,15 +147,14 @@ export default function AddStream() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<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">Streams</h1>
|
||||||
<h1 className="title">Streams</h1>
|
<p className="subtitle">
|
||||||
<p className="subtitle">
|
Organize your content by creating and managing stream sources
|
||||||
Organize your content by creating and managing stream sources
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add New Stream */}
|
{/* Add New Stream */}
|
||||||
<div className="glass p-6 mb-6">
|
<div className="glass p-6 mb-6">
|
||||||
|
@ -237,25 +206,25 @@ export default function AddStream() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Twitch Username */}
|
{/* URL */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-white font-semibold mb-3">
|
<label className="block text-white font-semibold mb-3">
|
||||||
Twitch Username
|
Stream URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="url"
|
||||||
name="twitch_username"
|
name="url"
|
||||||
value={formData.twitch_username}
|
value={formData.url}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
required
|
required
|
||||||
className={`input ${
|
className={`input ${
|
||||||
validationErrors.twitch_username ? 'border-red-500/60 bg-red-500/10' : ''
|
validationErrors.url ? 'border-red-500/60 bg-red-500/10' : ''
|
||||||
}`}
|
}`}
|
||||||
placeholder="Enter Twitch username"
|
placeholder="https://example.com/stream"
|
||||||
/>
|
/>
|
||||||
{validationErrors.twitch_username && (
|
{validationErrors.url && (
|
||||||
<div className="text-red-400 text-sm mt-2">
|
<div className="text-red-400 text-sm mt-2">
|
||||||
{validationErrors.twitch_username}
|
{validationErrors.url}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -266,7 +235,7 @@ export default function AddStream() {
|
||||||
Team
|
Team
|
||||||
</label>
|
</label>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div style={{ flex: 1, position: 'relative', zIndex: 10000 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
options={teams}
|
options={teams}
|
||||||
activeId={formData.team_id}
|
activeId={formData.team_id}
|
||||||
|
@ -327,30 +296,11 @@ export default function AddStream() {
|
||||||
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
<div className="text-sm text-white/60">Team: {team?.name || 'Unknown'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right space-y-2">
|
<div className="text-right">
|
||||||
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
<div className="text-sm text-white/40">ID: {stream.id}</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<a href={stream.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-400 hover:text-blue-300">
|
||||||
<a
|
View Stream
|
||||||
href={stream.url}
|
</a>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn btn-primary text-sm"
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
View Stream
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm({ id: stream.id, name: stream.name })}
|
|
||||||
className="btn btn-danger text-sm"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -360,51 +310,8 @@ export default function AddStream() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{deleteConfirm && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="glass p-6" style={{ maxWidth: '28rem', width: '90%' }}>
|
|
||||||
<h3 className="text-xl font-bold text-white mb-4">Confirm Deletion</h3>
|
|
||||||
<p className="text-white/80 mb-6">
|
|
||||||
Are you sure you want to delete the stream “{deleteConfirm.name}”? This will remove it from both the database and OBS.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="btn btn-danger"
|
|
||||||
>
|
|
||||||
{isDeleting ? 'Deleting...' : 'Delete Stream'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -11,8 +11,6 @@ export default function Teams() {
|
||||||
const [newTeamName, setNewTeamName] = useState('');
|
const [newTeamName, setNewTeamName] = useState('');
|
||||||
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [creatingGroupForTeam, setCreatingGroupForTeam] = useState<number | null>(null);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [updatingTeamId, setUpdatingTeamId] = useState<number | null>(null);
|
const [updatingTeamId, setUpdatingTeamId] = useState<number | null>(null);
|
||||||
const [deletingTeamId, setDeletingTeamId] = useState<number | null>(null);
|
const [deletingTeamId, setDeletingTeamId] = useState<number | null>(null);
|
||||||
|
@ -148,64 +146,6 @@ export default function Teams() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncAllGroups = async () => {
|
|
||||||
if (!confirm('This will create OBS groups for all teams that don\'t have one. Continue?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSyncing(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/syncGroups', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const result = await res.json();
|
|
||||||
fetchTeams();
|
|
||||||
showSuccess('Groups Synced', `${result.summary.successful} groups created successfully`);
|
|
||||||
if (result.summary.failed > 0) {
|
|
||||||
showError('Some Failures', `${result.summary.failed} groups failed to create`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const error = await res.json();
|
|
||||||
showError('Failed to Sync Groups', error.error || 'Unknown error occurred');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing groups:', error);
|
|
||||||
showError('Failed to Sync Groups', 'Network error or server unavailable');
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateGroup = async (teamId: number, teamName: string) => {
|
|
||||||
const groupName = prompt(`Enter group name for team "${teamName}":`, teamName);
|
|
||||||
if (!groupName) return;
|
|
||||||
|
|
||||||
setCreatingGroupForTeam(teamId);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/createGroup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ teamId, groupName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
fetchTeams();
|
|
||||||
showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`);
|
|
||||||
} else {
|
|
||||||
const error = await res.json();
|
|
||||||
showError('Failed to Create Group', error.error || 'Unknown error occurred');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating group:', error);
|
|
||||||
showError('Failed to Create Group', 'Network error or server unavailable');
|
|
||||||
} finally {
|
|
||||||
setCreatingGroupForTeam(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEditing = (team: Team) => {
|
const startEditing = (team: Team) => {
|
||||||
setEditingTeam(team);
|
setEditingTeam(team);
|
||||||
setEditingName(team.team_name);
|
setEditingName(team.team_name);
|
||||||
|
@ -269,18 +209,7 @@ export default function Teams() {
|
||||||
|
|
||||||
{/* Teams List */}
|
{/* Teams List */}
|
||||||
<div className="glass p-6">
|
<div className="glass p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<h2 className="card-title">Existing Teams</h2>
|
||||||
<h2 className="card-title">Existing Teams</h2>
|
|
||||||
<button
|
|
||||||
onClick={handleSyncAllGroups}
|
|
||||||
disabled={isSyncing || isLoading}
|
|
||||||
className="btn btn-success"
|
|
||||||
title="Create OBS groups for all teams without groups"
|
|
||||||
>
|
|
||||||
<span className="icon">🔄</span>
|
|
||||||
{isSyncing ? 'Syncing...' : 'Sync All Groups'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center p-8">
|
<div className="text-center p-8">
|
||||||
|
@ -298,7 +227,7 @@ export default function Teams() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<div key={team.team_id} className="glass p-4 mb-4">
|
<div key={team.team_id} className="glass p-4">
|
||||||
{editingTeam?.team_id === team.team_id ? (
|
{editingTeam?.team_id === team.team_id ? (
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input
|
<input
|
||||||
|
@ -337,25 +266,9 @@ export default function Teams() {
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-white">{team.team_name}</div>
|
<div className="font-semibold text-white">{team.team_name}</div>
|
||||||
<div className="text-sm text-white/60">ID: {team.team_id}</div>
|
<div className="text-sm text-white/60">ID: {team.team_id}</div>
|
||||||
{team.group_name ? (
|
|
||||||
<div className="text-sm text-green-400">OBS Group: {team.group_name}</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-orange-400">No OBS Group</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
{!team.group_name && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreateGroup(team.team_id, team.team_name)}
|
|
||||||
disabled={creatingGroupForTeam === team.team_id || deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
|
||||||
className="btn-success btn-sm"
|
|
||||||
title="Create OBS group"
|
|
||||||
>
|
|
||||||
<span className="icon">🎬</span>
|
|
||||||
{creatingGroupForTeam === team.team_id ? 'Creating...' : 'Create Group'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => startEditing(team)}
|
onClick={() => startEditing(team)}
|
||||||
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
type DropdownProps = {
|
type DropdownProps = {
|
||||||
options: Array<{ id: number; name: string }>;
|
options: Array<{ id: number; name: string }>;
|
||||||
|
@ -21,19 +20,12 @@ export default function Dropdown({
|
||||||
onToggle,
|
onToggle,
|
||||||
}: DropdownProps) {
|
}: DropdownProps) {
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(controlledIsOpen ?? false);
|
const [isOpen, setIsOpen] = useState(controlledIsOpen ?? false);
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (!dropdownRef.current || !buttonRef.current || !(event.target instanceof Node)) return;
|
if (!dropdownRef.current || !(event.target instanceof Node)) return;
|
||||||
if (!dropdownRef.current.contains(event.target) && !buttonRef.current.contains(event.target)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
if (onToggle) onToggle(false);
|
if (onToggle) onToggle(false);
|
||||||
else setIsOpen(false);
|
else setIsOpen(false);
|
||||||
}
|
}
|
||||||
|
@ -48,31 +40,6 @@ export default function Dropdown({
|
||||||
};
|
};
|
||||||
}, [controlledIsOpen, isOpen, onToggle]);
|
}, [controlledIsOpen, isOpen, onToggle]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updatePosition = () => {
|
|
||||||
if ((controlledIsOpen ?? isOpen) && buttonRef.current && mounted) {
|
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
|
||||||
setDropdownPosition({
|
|
||||||
top: rect.bottom + 4,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
|
|
||||||
if ((controlledIsOpen ?? isOpen) && mounted) {
|
|
||||||
window.addEventListener('scroll', updatePosition, true);
|
|
||||||
window.addEventListener('resize', updatePosition);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', updatePosition, true);
|
|
||||||
window.removeEventListener('resize', updatePosition);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [controlledIsOpen, isOpen, mounted]);
|
|
||||||
|
|
||||||
const activeOption = options.find((option) => option.id === activeId) || null;
|
const activeOption = options.find((option) => option.id === activeId) || null;
|
||||||
|
|
||||||
const handleSelect = (option: { id: number }) => {
|
const handleSelect = (option: { id: number }) => {
|
||||||
|
@ -86,65 +53,48 @@ export default function Dropdown({
|
||||||
else setIsOpen((prev) => !prev);
|
else setIsOpen((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownMenu = (controlledIsOpen ?? isOpen) && mounted ? (
|
return (
|
||||||
<div
|
<div className="relative w-full" ref={dropdownRef}>
|
||||||
ref={dropdownRef}
|
<button
|
||||||
className="dropdown-menu"
|
type="button"
|
||||||
style={{
|
onClick={toggleDropdown}
|
||||||
position: 'fixed',
|
className="dropdown-button"
|
||||||
top: dropdownPosition.top,
|
>
|
||||||
left: dropdownPosition.left,
|
<span>
|
||||||
width: dropdownPosition.width,
|
{activeOption ? activeOption.name : label}
|
||||||
zIndex: 999999
|
</span>
|
||||||
}}
|
<svg
|
||||||
>
|
className={`icon-sm transition-transform duration-200 ${(controlledIsOpen ?? isOpen) ? 'rotate-180' : ''}`}
|
||||||
{options.length === 0 ? (
|
fill="currentColor"
|
||||||
<div className="dropdown-item text-center">
|
viewBox="0 0 20 20"
|
||||||
No teams available
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a 1 1 0 01-1.414 0l-4-4a 1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(controlledIsOpen ?? isOpen) && (
|
||||||
|
<div className="absolute z-50 w-full dropdown-menu">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="dropdown-item text-center">
|
||||||
|
No streams available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
onClick={() => handleSelect(option)}
|
|
||||||
className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={toggleDropdown}
|
|
||||||
className="dropdown-button"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{activeOption ? activeOption.name : label}
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className={`icon-sm transition-transform duration-200 ${(controlledIsOpen ?? isOpen) ? 'rotate-180' : ''}`}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a 1 1 0 01-1.414 0l-4-4a 1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mounted && typeof document !== 'undefined' && dropdownMenu ?
|
|
||||||
createPortal(dropdownMenu, document.body) : null
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -10,7 +10,7 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock window.location.reload using jest.spyOn
|
// Mock window.location.reload using jest.spyOn
|
||||||
// const mockReload = jest.fn(); // Defined but not used in current tests
|
const mockReload = jest.fn();
|
||||||
|
|
||||||
describe('ErrorBoundary', () => {
|
describe('ErrorBoundary', () => {
|
||||||
// Suppress console.error for these tests since we expect errors
|
// Suppress console.error for these tests since we expect errors
|
||||||
|
@ -63,7 +63,7 @@ describe('ErrorBoundary', () => {
|
||||||
it('calls window.location.reload when refresh button is clicked', () => {
|
it('calls window.location.reload when refresh button is clicked', () => {
|
||||||
// Skip this test in jsdom environment as window.location.reload cannot be easily mocked
|
// Skip this test in jsdom environment as window.location.reload cannot be easily mocked
|
||||||
// In a real browser environment, this would work as expected
|
// In a real browser environment, this would work as expected
|
||||||
// const originalReload = window.location.reload; // Not used in jsdom test
|
const originalReload = window.location.reload;
|
||||||
|
|
||||||
// Simple workaround for jsdom limitation
|
// Simple workaround for jsdom limitation
|
||||||
if (typeof window.location.reload !== 'function') {
|
if (typeof window.location.reload !== 'function') {
|
||||||
|
@ -119,17 +119,11 @@ describe('ErrorBoundary', () => {
|
||||||
const originalEnv = process.env.NODE_ENV;
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = 'development';
|
||||||
value: 'development',
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = originalEnv;
|
||||||
value: originalEnv,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error details in development mode', () => {
|
it('shows error details in development mode', () => {
|
||||||
|
@ -153,17 +147,11 @@ describe('ErrorBoundary', () => {
|
||||||
const originalEnv = process.env.NODE_ENV;
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = 'production';
|
||||||
value: 'production',
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = originalEnv;
|
||||||
value: originalEnv,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides error details in production mode', () => {
|
it('hides error details in production mode', () => {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
import { ToastComponent, ToastContainer, Toast, ToastType } from '../Toast';
|
import { ToastComponent, ToastContainer, Toast, ToastType } from '../Toast';
|
||||||
|
|
||||||
// Mock timer functions
|
// Mock timer functions
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
# OBS Source Switcher Setup Guide
|
|
||||||
|
|
||||||
This document explains how to configure OBS Studio to work with the Source Switcher Plugin UI.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. OBS Studio installed
|
|
||||||
2. [OBS WebSocket plugin](https://github.com/obsproject/obs-websocket) (usually included with OBS 28+)
|
|
||||||
3. [OBS Source Switcher plugin](https://obsproject.com/forum/resources/source-switcher.1090/) installed
|
|
||||||
|
|
||||||
## Required Source Switcher Names
|
|
||||||
|
|
||||||
You must create **exactly 7 Source Switcher sources** in OBS with these specific names:
|
|
||||||
|
|
||||||
| Source Switcher Name | Screen Position | Text File |
|
|
||||||
|---------------------|-----------------|-----------|
|
|
||||||
| `ss_large` | Main/Large screen | `large.txt` |
|
|
||||||
| `ss_left` | Left screen | `left.txt` |
|
|
||||||
| `ss_right` | Right screen | `right.txt` |
|
|
||||||
| `ss_top_left` | Top left corner | `topLeft.txt` |
|
|
||||||
| `ss_top_right` | Top right corner | `topRight.txt` |
|
|
||||||
| `ss_bottom_left` | Bottom left corner | `bottomLeft.txt` |
|
|
||||||
| `ss_bottom_right` | Bottom right corner | `bottomRight.txt` |
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### 1. Configure OBS WebSocket
|
|
||||||
|
|
||||||
1. In OBS, go to **Tools → WebSocket Server Settings**
|
|
||||||
2. Enable the WebSocket server
|
|
||||||
3. Set a port (default: 4455)
|
|
||||||
4. Optionally set a password
|
|
||||||
5. Note these settings for your `.env.local` file
|
|
||||||
|
|
||||||
### 2. Create Source Switcher Sources
|
|
||||||
|
|
||||||
For each screen position:
|
|
||||||
|
|
||||||
1. In OBS, click the **+** button in Sources
|
|
||||||
2. Select **Source Switcher**
|
|
||||||
3. Name it exactly as shown in the table above (e.g., `ss_large`)
|
|
||||||
4. Configure the Source Switcher:
|
|
||||||
- **Mode**: Text File
|
|
||||||
- **File Path**: Point to the corresponding text file in your `files` directory
|
|
||||||
- **Switch Behavior**: Choose your preferred transition
|
|
||||||
|
|
||||||
### 3. Configure Text File Monitoring
|
|
||||||
|
|
||||||
Each Source Switcher should monitor its corresponding text file:
|
|
||||||
|
|
||||||
- `ss_large` → monitors `{FILE_DIRECTORY}/large.txt`
|
|
||||||
- `ss_left` → monitors `{FILE_DIRECTORY}/left.txt`
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
Where `{FILE_DIRECTORY}` is the path configured in your `.env.local` file (default: `./files`)
|
|
||||||
|
|
||||||
### 4. Add Browser Sources
|
|
||||||
|
|
||||||
When you add streams through the UI, browser sources are automatically created in OBS with these settings:
|
|
||||||
- **Width**: 1600px
|
|
||||||
- **Height**: 900px
|
|
||||||
- **Audio**: Controlled via OBS (muted by default)
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. **Stream Selection**: When you select a stream for a screen position in the UI
|
|
||||||
2. **File Update**: The app writes the OBS source name to the corresponding text file
|
|
||||||
3. **Source Switch**: The Source Switcher detects the file change and switches to that source
|
|
||||||
4. **Group Organization**: Streams are organized into OBS groups based on their teams
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Source Switcher not switching
|
|
||||||
- Verify the text file path is correct
|
|
||||||
- Check that the file is being updated (manually open the .txt file)
|
|
||||||
- Ensure Source Switcher is set to "Text File" mode
|
|
||||||
|
|
||||||
### Sources not appearing
|
|
||||||
- Check OBS WebSocket connection in the footer
|
|
||||||
- Verify WebSocket credentials in `.env.local`
|
|
||||||
- Ensure the source name doesn't already exist in OBS
|
|
||||||
|
|
||||||
### Missing screen positions
|
|
||||||
- Verify all 7 Source Switchers are created with exact names
|
|
||||||
- Check for typos in source names (they must match exactly)
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Configure these in your `.env.local` file:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# OBS WebSocket Settings
|
|
||||||
OBS_WEBSOCKET_HOST=127.0.0.1
|
|
||||||
OBS_WEBSOCKET_PORT=4455
|
|
||||||
OBS_WEBSOCKET_PASSWORD=your_password_here
|
|
||||||
|
|
||||||
# File Directory (where text files are stored)
|
|
||||||
FILE_DIRECTORY=./files
|
|
||||||
```
|
|
998
files/SaT.json
998
files/SaT.json
|
@ -1,998 +0,0 @@
|
||||||
{
|
|
||||||
"current_scene": "Scene",
|
|
||||||
"current_program_scene": "Scene",
|
|
||||||
"scene_order": [
|
|
||||||
{
|
|
||||||
"name": "Scene"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "1-Screen"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "2-Screen"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "4-Screen"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Testers"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "SaT",
|
|
||||||
"groups": [],
|
|
||||||
"quick_transitions": [
|
|
||||||
{
|
|
||||||
"name": "Cut",
|
|
||||||
"duration": 300,
|
|
||||||
"hotkeys": [],
|
|
||||||
"id": 4,
|
|
||||||
"fade_to_black": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fade",
|
|
||||||
"duration": 300,
|
|
||||||
"hotkeys": [],
|
|
||||||
"id": 5,
|
|
||||||
"fade_to_black": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fade",
|
|
||||||
"duration": 300,
|
|
||||||
"hotkeys": [],
|
|
||||||
"id": 6,
|
|
||||||
"fade_to_black": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"transitions": [],
|
|
||||||
"saved_projectors": [],
|
|
||||||
"canvases": [],
|
|
||||||
"current_transition": "Fade",
|
|
||||||
"transition_duration": 300,
|
|
||||||
"preview_locked": false,
|
|
||||||
"scaling_enabled": false,
|
|
||||||
"scaling_level": -8,
|
|
||||||
"scaling_off_x": 0.0,
|
|
||||||
"scaling_off_y": 0.0,
|
|
||||||
"modules": {
|
|
||||||
"scripts-tool": [],
|
|
||||||
"output-timer": {
|
|
||||||
"streamTimerHours": 0,
|
|
||||||
"streamTimerMinutes": 0,
|
|
||||||
"streamTimerSeconds": 30,
|
|
||||||
"recordTimerHours": 0,
|
|
||||||
"recordTimerMinutes": 0,
|
|
||||||
"recordTimerSeconds": 30,
|
|
||||||
"autoStartStreamTimer": false,
|
|
||||||
"autoStartRecordTimer": false,
|
|
||||||
"pauseRecordTimer": true
|
|
||||||
},
|
|
||||||
"auto-scene-switcher": {
|
|
||||||
"interval": 300,
|
|
||||||
"non_matching_scene": "",
|
|
||||||
"switch_if_not_matching": false,
|
|
||||||
"active": false,
|
|
||||||
"switches": []
|
|
||||||
},
|
|
||||||
"captions": {
|
|
||||||
"source": "",
|
|
||||||
"enabled": false,
|
|
||||||
"lang_id": 1033,
|
|
||||||
"provider": "mssapi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 2,
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "1-Screen",
|
|
||||||
"uuid": "a147f8e6-092d-4271-b1f6-8677b0644699",
|
|
||||||
"id": "scene",
|
|
||||||
"versioned_id": "scene",
|
|
||||||
"settings": {
|
|
||||||
"id_counter": 1,
|
|
||||||
"custom_size": false,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "ss_large",
|
|
||||||
"source_uuid": "e5727f76-0f05-4747-93fe-190d0e27fad8",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 1,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"OBSBasic.SelectScene": [],
|
|
||||||
"libobs.show_scene_item.1": [],
|
|
||||||
"libobs.hide_scene_item.1": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "2-Screen",
|
|
||||||
"uuid": "e3fa43b2-84ff-46cf-b56c-6110a31df1ad",
|
|
||||||
"id": "scene",
|
|
||||||
"versioned_id": "scene",
|
|
||||||
"settings": {
|
|
||||||
"id_counter": 0,
|
|
||||||
"custom_size": false,
|
|
||||||
"items": []
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"OBSBasic.SelectScene": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "4-Screen",
|
|
||||||
"uuid": "ec2099f7-a728-49fc-95ee-c19ca5987bda",
|
|
||||||
"id": "scene",
|
|
||||||
"versioned_id": "scene",
|
|
||||||
"settings": {
|
|
||||||
"id_counter": 1,
|
|
||||||
"custom_size": false,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "ss_bottom_left",
|
|
||||||
"source_uuid": "e3c901fa-60da-4e62-9104-f6e5c8c2dabd",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 1,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"OBSBasic.SelectScene": [],
|
|
||||||
"libobs.show_scene_item.1": [],
|
|
||||||
"libobs.hide_scene_item.1": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "Scene",
|
|
||||||
"uuid": "9a7e85d1-30bd-4e1a-8836-2d20b385820c",
|
|
||||||
"id": "scene",
|
|
||||||
"versioned_id": "scene",
|
|
||||||
"settings": {
|
|
||||||
"id_counter": 7,
|
|
||||||
"custom_size": false,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "ss_top_left",
|
|
||||||
"source_uuid": "cd24ff7b-7e4b-4aef-a224-6af032de6247",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 1,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_top_right",
|
|
||||||
"source_uuid": "b38b1134-7040-44b4-a397-1a1385911403",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 2,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_bottom_left",
|
|
||||||
"source_uuid": "e3c901fa-60da-4e62-9104-f6e5c8c2dabd",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 3,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_bottom_right",
|
|
||||||
"source_uuid": "c2164d12-5a03-4372-acdd-f93a7db2a166",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 4,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_large",
|
|
||||||
"source_uuid": "e5727f76-0f05-4747-93fe-190d0e27fad8",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 5,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_left",
|
|
||||||
"source_uuid": "0f02c57d-41fd-40c3-8e05-de44edc52361",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 6,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ss_right",
|
|
||||||
"source_uuid": "a90a0e5b-4443-4ac1-981c-6598b92c47fe",
|
|
||||||
"visible": true,
|
|
||||||
"locked": false,
|
|
||||||
"rot": 0.0,
|
|
||||||
"scale_ref": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 1080.0
|
|
||||||
},
|
|
||||||
"align": 5,
|
|
||||||
"bounds_type": 0,
|
|
||||||
"bounds_align": 0,
|
|
||||||
"bounds_crop": false,
|
|
||||||
"crop_left": 0,
|
|
||||||
"crop_top": 0,
|
|
||||||
"crop_right": 0,
|
|
||||||
"crop_bottom": 0,
|
|
||||||
"id": 7,
|
|
||||||
"group_item_backup": false,
|
|
||||||
"pos": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"pos_rel": {
|
|
||||||
"x": -1.7777777910232544,
|
|
||||||
"y": -1.0
|
|
||||||
},
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"scale_rel": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"bounds": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"bounds_rel": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 0.0
|
|
||||||
},
|
|
||||||
"scale_filter": "disable",
|
|
||||||
"blend_method": "default",
|
|
||||||
"blend_type": "normal",
|
|
||||||
"show_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"hide_transition": {
|
|
||||||
"duration": 0
|
|
||||||
},
|
|
||||||
"private_settings": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"OBSBasic.SelectScene": [],
|
|
||||||
"libobs.show_scene_item.1": [],
|
|
||||||
"libobs.hide_scene_item.1": [],
|
|
||||||
"libobs.show_scene_item.2": [],
|
|
||||||
"libobs.hide_scene_item.2": [],
|
|
||||||
"libobs.show_scene_item.3": [],
|
|
||||||
"libobs.hide_scene_item.3": [],
|
|
||||||
"libobs.show_scene_item.4": [],
|
|
||||||
"libobs.hide_scene_item.4": [],
|
|
||||||
"libobs.show_scene_item.5": [],
|
|
||||||
"libobs.hide_scene_item.5": [],
|
|
||||||
"libobs.show_scene_item.6": [],
|
|
||||||
"libobs.hide_scene_item.6": [],
|
|
||||||
"libobs.show_scene_item.7": [],
|
|
||||||
"libobs.hide_scene_item.7": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_bottom_left",
|
|
||||||
"uuid": "e3c901fa-60da-4e62-9104-f6e5c8c2dabd",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_bottom_right",
|
|
||||||
"uuid": "c2164d12-5a03-4372-acdd-f93a7db2a166",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_large",
|
|
||||||
"uuid": "e5727f76-0f05-4747-93fe-190d0e27fad8",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1,
|
|
||||||
"current_source_file": true,
|
|
||||||
"current_source_file_path": "C:/Users/derek/OBS/SaT Summer 2025/ss/ss-large.txt",
|
|
||||||
"current_source_file_interval": 1000
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_left",
|
|
||||||
"uuid": "0f02c57d-41fd-40c3-8e05-de44edc52361",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_right",
|
|
||||||
"uuid": "a90a0e5b-4443-4ac1-981c-6598b92c47fe",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_top_left",
|
|
||||||
"uuid": "cd24ff7b-7e4b-4aef-a224-6af032de6247",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "ss_top_right",
|
|
||||||
"uuid": "b38b1134-7040-44b4-a397-1a1385911403",
|
|
||||||
"id": "source_switcher",
|
|
||||||
"versioned_id": "source_switcher",
|
|
||||||
"settings": {
|
|
||||||
"current_index": -1
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"none": [],
|
|
||||||
"next": [],
|
|
||||||
"previous": [],
|
|
||||||
"random": [],
|
|
||||||
"shuffle": [],
|
|
||||||
"first": [],
|
|
||||||
"last": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"private_settings": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"prev_ver": 520159233,
|
|
||||||
"name": "Testers",
|
|
||||||
"uuid": "10ab7435-3634-4d18-ab64-18385c68a106",
|
|
||||||
"id": "scene",
|
|
||||||
"versioned_id": "scene",
|
|
||||||
"settings": {
|
|
||||||
"id_counter": 0,
|
|
||||||
"custom_size": false,
|
|
||||||
"items": []
|
|
||||||
},
|
|
||||||
"mixers": 0,
|
|
||||||
"sync": 0,
|
|
||||||
"flags": 0,
|
|
||||||
"volume": 1.0,
|
|
||||||
"balance": 0.5,
|
|
||||||
"enabled": true,
|
|
||||||
"muted": false,
|
|
||||||
"push-to-mute": false,
|
|
||||||
"push-to-mute-delay": 0,
|
|
||||||
"push-to-talk": false,
|
|
||||||
"push-to-talk-delay": 0,
|
|
||||||
"hotkeys": {
|
|
||||||
"OBSBasic.SelectScene": []
|
|
||||||
},
|
|
||||||
"deinterlace_mode": 0,
|
|
||||||
"deinterlace_field_order": 0,
|
|
||||||
"monitoring_type": 0,
|
|
||||||
"canvas_uuid": "6c69626f-6273-4c00-9d88-c5136d61696e",
|
|
||||||
"private_settings": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -32,7 +32,7 @@ describe('apiHelpers', () => {
|
||||||
|
|
||||||
describe('createErrorResponse', () => {
|
describe('createErrorResponse', () => {
|
||||||
it('creates error response with default status 500', () => {
|
it('creates error response with default status 500', () => {
|
||||||
const _response = createErrorResponse('Test Error');
|
const response = createErrorResponse('Test Error');
|
||||||
|
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -44,7 +44,7 @@ describe('apiHelpers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates error response with custom status and message', () => {
|
it('creates error response with custom status and message', () => {
|
||||||
const _response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
|
const response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
|
||||||
|
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -81,7 +81,7 @@ describe('apiHelpers', () => {
|
||||||
describe('createSuccessResponse', () => {
|
describe('createSuccessResponse', () => {
|
||||||
it('creates success response with default status 200', () => {
|
it('creates success response with default status 200', () => {
|
||||||
const data = { test: 'data' };
|
const data = { test: 'data' };
|
||||||
const _response = createSuccessResponse(data);
|
const response = createSuccessResponse(data);
|
||||||
|
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -95,7 +95,7 @@ describe('apiHelpers', () => {
|
||||||
|
|
||||||
it('creates success response with custom status', () => {
|
it('creates success response with custom status', () => {
|
||||||
const data = { id: 1, name: 'test' };
|
const data = { id: 1, name: 'test' };
|
||||||
const _response = createSuccessResponse(data, 201);
|
const response = createSuccessResponse(data, 201);
|
||||||
|
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -151,9 +151,9 @@ describe('apiHelpers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseRequestBody', () => {
|
describe('parseRequestBody', () => {
|
||||||
const mockRequest = (body: unknown): Request => ({
|
const mockRequest = (body: any): Request => ({
|
||||||
json: jest.fn().mockResolvedValue(body),
|
json: jest.fn().mockResolvedValue(body),
|
||||||
} as unknown as Request);
|
} as any);
|
||||||
|
|
||||||
it('parses valid JSON body without validator', async () => {
|
it('parses valid JSON body without validator', async () => {
|
||||||
const body = { name: 'test', value: 123 };
|
const body = { name: 'test', value: 123 };
|
||||||
|
@ -170,7 +170,7 @@ describe('apiHelpers', () => {
|
||||||
it('handles invalid JSON', async () => {
|
it('handles invalid JSON', async () => {
|
||||||
const request = {
|
const request = {
|
||||||
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
|
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||||
} as unknown as Request;
|
} as any;
|
||||||
|
|
||||||
const result = await parseRequestBody(request);
|
const result = await parseRequestBody(request);
|
||||||
|
|
||||||
|
@ -218,17 +218,11 @@ describe('apiHelpers', () => {
|
||||||
const originalEnv = process.env.NODE_ENV;
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = originalEnv;
|
||||||
value: originalEnv,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes error details in development', () => {
|
it('includes error details in development', () => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = 'development';
|
||||||
value: 'development',
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
const originalError = new Error('Test error');
|
const originalError = new Error('Test error');
|
||||||
|
|
||||||
createDatabaseError('test operation', originalError);
|
createDatabaseError('test operation', originalError);
|
||||||
|
@ -242,10 +236,7 @@ describe('apiHelpers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes error details in production', () => {
|
it('excludes error details in production', () => {
|
||||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
process.env.NODE_ENV = 'production';
|
||||||
value: 'production',
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
const originalError = new Error('Test error');
|
const originalError = new Error('Test error');
|
||||||
|
|
||||||
createDatabaseError('test operation', originalError);
|
createDatabaseError('test operation', originalError);
|
||||||
|
|
|
@ -178,7 +178,7 @@ describe('useToast', () => {
|
||||||
it('returns unique IDs for each toast', () => {
|
it('returns unique IDs for each toast', () => {
|
||||||
const { result } = renderHook(() => useToast());
|
const { result } = renderHook(() => useToast());
|
||||||
|
|
||||||
let id1: string = '', id2: string = '';
|
let id1: string, id2: string;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
// Mock different random values for unique IDs
|
// Mock different random values for unique IDs
|
||||||
|
|
|
@ -18,7 +18,7 @@ export async function apiCall(url: string, options: RequestInit = {}): Promise<R
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers as Record<string, string> || {}),
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add API key if available
|
// Add API key if available
|
||||||
|
|
|
@ -140,7 +140,7 @@ export async function parseRequestBody<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: body as T };
|
return { success: true, data: body as T };
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
response: createErrorResponse(
|
response: createErrorResponse(
|
||||||
|
|
110
lib/obsClient.js
110
lib/obsClient.js
|
@ -120,74 +120,48 @@ async function addSourceToSwitcher(inputName, newSources) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGroupIfNotExists(groupName) {
|
// async function addSourceToGroup(obs, teamName, obs_source_name, url) {
|
||||||
try {
|
// try {
|
||||||
const obsClient = await getOBSClient();
|
// // Step 1: Check if the group exists
|
||||||
|
// const { scenes } = await obs.call('GetSceneList');
|
||||||
// Check if the group (scene) exists
|
// const groupExists = scenes.some((scene) => scene.sceneName === teamName);
|
||||||
const { scenes } = await obsClient.call('GetSceneList');
|
|
||||||
const groupExists = scenes.some((scene) => scene.sceneName === groupName);
|
|
||||||
|
|
||||||
if (!groupExists) {
|
// // Step 2: Create the group if it doesn't exist
|
||||||
console.log(`Creating group "${groupName}"`);
|
// if (!groupExists) {
|
||||||
await obsClient.call('CreateScene', { sceneName: groupName });
|
// console.log(`Group "${teamName}" does not exist. Creating it.`);
|
||||||
return { created: true, message: `Group "${groupName}" created successfully` };
|
// await obs.call('CreateScene', { sceneName: teamName });
|
||||||
} else {
|
// } else {
|
||||||
console.log(`Group "${groupName}" already exists`);
|
// console.log(`Group "${teamName}" already exists.`);
|
||||||
return { created: false, message: `Group "${groupName}" already exists` };
|
// }
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating group:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSourceToGroup(groupName, sourceName, url) {
|
// // Step 3: Add the source to the group
|
||||||
try {
|
// console.log(`Adding source "${obs_source_name}" to group "${teamName}".`);
|
||||||
const obsClient = await getOBSClient();
|
// await obs.call('CreateInput', {
|
||||||
|
// sceneName: teamName,
|
||||||
// Ensure group exists
|
// inputName: obs_source_name,
|
||||||
await createGroupIfNotExists(groupName);
|
// inputKind: 'browser_source',
|
||||||
|
// inputSettings: {
|
||||||
// Check if source already exists in the group
|
// width: 1600,
|
||||||
const { sceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName });
|
// height: 900,
|
||||||
const sourceExists = sceneItems.some(item => item.sourceName === sourceName);
|
// url,
|
||||||
|
// control_audio: true,
|
||||||
if (!sourceExists) {
|
// },
|
||||||
// Create the browser source in the group
|
// });
|
||||||
console.log(`Adding source "${sourceName}" to group "${groupName}"`);
|
|
||||||
await obsClient.call('CreateInput', {
|
// // Step 4: Enable "Control audio via OBS"
|
||||||
sceneName: groupName,
|
// await obs.call('SetInputSettings', {
|
||||||
inputName: sourceName,
|
// inputName: obs_source_name,
|
||||||
inputKind: 'browser_source',
|
// inputSettings: {
|
||||||
inputSettings: {
|
// control_audio: true, // Enable audio control
|
||||||
width: 1600,
|
// },
|
||||||
height: 900,
|
// overlay: true, // Keep existing settings and apply changes
|
||||||
url,
|
// });
|
||||||
control_audio: true,
|
|
||||||
},
|
// console.log(`Source "${obs_source_name}" successfully added to group "${teamName}".`);
|
||||||
});
|
// } catch (error) {
|
||||||
|
// console.error('Error adding source to group:', error.message);
|
||||||
// Ensure audio control is enabled
|
// }
|
||||||
await obsClient.call('SetInputSettings', {
|
// }
|
||||||
inputName: sourceName,
|
|
||||||
inputSettings: {
|
|
||||||
control_audio: true,
|
|
||||||
},
|
|
||||||
overlay: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Source "${sourceName}" successfully added to group "${groupName}"`);
|
|
||||||
return { success: true, message: `Source added to group successfully` };
|
|
||||||
} else {
|
|
||||||
console.log(`Source "${sourceName}" already exists in group "${groupName}"`);
|
|
||||||
return { success: false, message: `Source already exists in group` };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding source to group:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Export all functions
|
// Export all functions
|
||||||
|
@ -197,7 +171,5 @@ module.exports = {
|
||||||
disconnectFromOBS,
|
disconnectFromOBS,
|
||||||
addSourceToSwitcher,
|
addSourceToSwitcher,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
getConnectionStatus,
|
getConnectionStatus
|
||||||
createGroupIfNotExists,
|
|
||||||
addSourceToGroup
|
|
||||||
};
|
};
|
|
@ -3,12 +3,11 @@
|
||||||
import React, { useMemo, useCallback, useRef } from 'react';
|
import React, { useMemo, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
// Debounce hook for preventing excessive API calls
|
// Debounce hook for preventing excessive API calls
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function useDebounce<T extends (...args: any[]) => any>(
|
export function useDebounce<T extends (...args: any[]) => any>(
|
||||||
callback: T,
|
callback: T,
|
||||||
delay: number
|
delay: number
|
||||||
): T {
|
): T {
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
return useCallback((...args: Parameters<T>) => {
|
return useCallback((...args: Parameters<T>) => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
|
@ -22,7 +21,7 @@ export function useDebounce<T extends (...args: any[]) => any>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle hook for limiting function calls
|
// Throttle hook for limiting function calls
|
||||||
export function useThrottle<T extends (...args: unknown[]) => unknown>(
|
export function useThrottle<T extends (...args: any[]) => any>(
|
||||||
callback: T,
|
callback: T,
|
||||||
delay: number
|
delay: number
|
||||||
): T {
|
): T {
|
||||||
|
@ -39,21 +38,16 @@ export function useThrottle<T extends (...args: unknown[]) => unknown>(
|
||||||
|
|
||||||
// Memoized stream lookup utilities
|
// Memoized stream lookup utilities
|
||||||
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
||||||
const sourceToIdMap = new Map<string, number>();
|
|
||||||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
|
||||||
|
|
||||||
streams.forEach(stream => {
|
|
||||||
sourceToIdMap.set(stream.obs_source_name, stream.id);
|
|
||||||
idToStreamMap.set(stream.id, stream);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { sourceToIdMap, idToStreamMap };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook version for React components
|
|
||||||
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return createStreamLookupMaps(streams);
|
const sourceToIdMap = new Map<string, number>();
|
||||||
|
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
||||||
|
|
||||||
|
streams.forEach(stream => {
|
||||||
|
sourceToIdMap.set(stream.obs_source_name, stream.id);
|
||||||
|
idToStreamMap.set(stream.id, stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sourceToIdMap, idToStreamMap };
|
||||||
}, [streams]);
|
}, [streams]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +56,7 @@ export function useActiveSourceLookup(
|
||||||
streams: Array<{ id: number; obs_source_name: string; name: string }>,
|
streams: Array<{ id: number; obs_source_name: string; name: string }>,
|
||||||
activeSources: Record<string, string | null>
|
activeSources: Record<string, string | null>
|
||||||
) {
|
) {
|
||||||
const { sourceToIdMap } = useStreamLookupMaps(streams);
|
const { sourceToIdMap } = createStreamLookupMaps(streams);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const activeSourceIds: Record<string, number | null> = {};
|
const activeSourceIds: Record<string, number | null> = {};
|
||||||
|
@ -110,7 +104,7 @@ export class PerformanceMonitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
static getAllMetrics() {
|
static getAllMetrics() {
|
||||||
const result: Record<string, ReturnType<typeof PerformanceMonitor.getMetrics>> = {};
|
const result: Record<string, any> = {};
|
||||||
this.metrics.forEach((_, label) => {
|
this.metrics.forEach((_, label) => {
|
||||||
result[label] = this.getMetrics(label);
|
result[label] = this.getMetrics(label);
|
||||||
});
|
});
|
||||||
|
@ -161,11 +155,11 @@ export function usePageVisibility() {
|
||||||
export function useSmartPolling(
|
export function useSmartPolling(
|
||||||
callback: () => void | Promise<void>,
|
callback: () => void | Promise<void>,
|
||||||
interval: number,
|
interval: number,
|
||||||
dependencies: unknown[] = []
|
dependencies: any[] = []
|
||||||
) {
|
) {
|
||||||
const isVisible = usePageVisibility();
|
const isVisible = usePageVisibility();
|
||||||
const callbackRef = useRef(callback);
|
const callbackRef = useRef(callback);
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
// Update callback ref
|
// Update callback ref
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -173,24 +167,22 @@ export function useSmartPolling(
|
||||||
}, [callback]);
|
}, [callback]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Clear any existing interval before setting up a new one
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
// Start polling when visible
|
// Start polling when visible
|
||||||
callbackRef.current();
|
callbackRef.current();
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
callbackRef.current();
|
callbackRef.current();
|
||||||
}, interval);
|
}, interval);
|
||||||
|
} else {
|
||||||
|
// Stop polling when not visible
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [interval, isVisible, ...dependencies]);
|
}, [interval, isVisible, ...dependencies]);
|
||||||
|
|
|
@ -18,15 +18,7 @@ export function isValidUrl(url: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPositiveInteger(value: unknown): value is number {
|
export function isPositiveInteger(value: unknown): value is number {
|
||||||
return Number.isInteger(value) && Number(value) > 0;
|
return Number.isInteger(value) && value > 0;
|
||||||
}
|
|
||||||
|
|
||||||
export function validateInteger(value: unknown): number | null {
|
|
||||||
const num = Number(value);
|
|
||||||
if (Number.isInteger(num) && num > 0) {
|
|
||||||
return num;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// String sanitization
|
// String sanitization
|
||||||
|
|
|
@ -21,10 +21,7 @@ export function middleware(request: NextRequest) {
|
||||||
// Skip authentication for localhost/internal requests (optional security)
|
// Skip authentication for localhost/internal requests (optional security)
|
||||||
const host = request.headers.get('host');
|
const host = request.headers.get('host');
|
||||||
if (host && (host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('192.168.'))) {
|
if (host && (host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('192.168.'))) {
|
||||||
// Don't log for frequently polled endpoints to reduce noise
|
console.log('Allowing internal network access without API key');
|
||||||
if (!request.nextUrl.pathname.includes('/api/obsStatus')) {
|
|
||||||
console.log('Allowing internal network access without API key');
|
|
||||||
}
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import { open } from 'sqlite';
|
|
||||||
import path from 'path';
|
|
||||||
import { getTableName, BASE_TABLE_NAMES } from '../lib/constants';
|
|
||||||
|
|
||||||
const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files');
|
|
||||||
|
|
||||||
const addGroupNameToTeams = async () => {
|
|
||||||
try {
|
|
||||||
const dbPath = path.join(FILE_DIRECTORY, 'sources.db');
|
|
||||||
|
|
||||||
// Open database connection
|
|
||||||
const db = await open({
|
|
||||||
filename: dbPath,
|
|
||||||
driver: sqlite3.Database,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Database connection established.');
|
|
||||||
|
|
||||||
// Generate table name for teams
|
|
||||||
const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, {
|
|
||||||
year: 2025,
|
|
||||||
season: 'summer',
|
|
||||||
suffix: 'sat'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Adding group_name column to ${teamsTableName}`);
|
|
||||||
|
|
||||||
// Check if column already exists
|
|
||||||
const tableInfo = await db.all(`PRAGMA table_info(${teamsTableName})`);
|
|
||||||
const hasGroupName = tableInfo.some(col => col.name === 'group_name');
|
|
||||||
|
|
||||||
if (!hasGroupName) {
|
|
||||||
// Add group_name column
|
|
||||||
await db.exec(`
|
|
||||||
ALTER TABLE ${teamsTableName}
|
|
||||||
ADD COLUMN group_name TEXT
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(`✅ Added group_name column to ${teamsTableName}`);
|
|
||||||
} else {
|
|
||||||
console.log(`ℹ️ group_name column already exists in ${teamsTableName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close database connection
|
|
||||||
await db.close();
|
|
||||||
console.log('Database connection closed.');
|
|
||||||
console.log('✅ Successfully updated teams table schema!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating table:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the script
|
|
||||||
addGroupNameToTeams();
|
|
|
@ -60,8 +60,7 @@ const createSatSummer2025Tables = async () => {
|
||||||
await db.exec(`
|
await db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ${teamsTableName} (
|
CREATE TABLE IF NOT EXISTS ${teamsTableName} (
|
||||||
team_id INTEGER PRIMARY KEY,
|
team_id INTEGER PRIMARY KEY,
|
||||||
team_name TEXT NOT NULL,
|
team_name TEXT NOT NULL
|
||||||
group_name TEXT
|
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,6 @@
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js", "types/**/*.d.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,5 +14,4 @@ export type Screen = {
|
||||||
export type Team = {
|
export type Team = {
|
||||||
team_id: number;
|
team_id: number;
|
||||||
team_name: string;
|
team_name: string;
|
||||||
group_name?: string | null;
|
|
||||||
};
|
};
|
10
types/jest-dom.d.ts
vendored
10
types/jest-dom.d.ts
vendored
|
@ -1,10 +0,0 @@
|
||||||
// Jest DOM type definitions
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace NodeJS {
|
|
||||||
interface ProcessEnv {
|
|
||||||
NODE_ENV: 'development' | 'production' | 'test';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue