diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index a5313f1..5a7d0b0 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -9,21 +9,12 @@ on: jobs: build: runs-on: self-hosted - - strategy: - matrix: - node-version: [ 20, 22 ] + # Note: Node.js is pre-installed on self-hosted runners steps: - name: Checkout Repository 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 run: rm -rf .next @@ -40,8 +31,7 @@ jobs: run: npm run build - name: Upload Build Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: - name: obs-ss-${{ matrix.node-version }} - include-hidden-files: 'true' + name: obs-ss-build path: ./.next/* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e9b1a8b..b1cbf06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I #### Team Management - `GET /api/teams` - Get all teams - `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 - `GET /api/obsStatus` - Real-time OBS connection and streaming status @@ -92,7 +94,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I Dynamic table names with seasonal configuration: - `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id -- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name +- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name ### OBS Integration Pattern @@ -100,6 +102,18 @@ The app uses a sophisticated dual integration approach: 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 +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**: 1. User selects stream in React UI diff --git a/app/api/__tests__/streams.test.ts b/app/api/__tests__/streams.test.ts index d4ef664..f57f060 100644 --- a/app/api/__tests__/streams.test.ts +++ b/app/api/__tests__/streams.test.ts @@ -6,7 +6,7 @@ jest.mock('@/lib/database', () => ({ })); describe('/api/streams', () => { - let mockDb: any; + let mockDb: { all: jest.Mock }; beforeEach(() => { // Create mock database @@ -27,7 +27,7 @@ describe('/api/streams', () => { mockDb.all.mockResolvedValue(mockStreams); - const response = await GET(); + const _response = await GET(); expect(mockDb.all).toHaveBeenCalledWith( expect.stringContaining('SELECT * FROM') @@ -40,7 +40,7 @@ describe('/api/streams', () => { it('returns empty array when no streams exist', async () => { mockDb.all.mockResolvedValue([]); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith([]); @@ -50,7 +50,7 @@ describe('/api/streams', () => { const dbError = new Error('Database connection failed'); mockDb.all.mockRejectedValue(dbError); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith( @@ -64,7 +64,7 @@ describe('/api/streams', () => { const { getDatabase } = require('@/lib/database'); getDatabase.mockRejectedValue(connectionError); - const response = await GET(); + const _response = await GET(); const { NextResponse } = require('next/server'); expect(NextResponse.json).toHaveBeenCalledWith( diff --git a/app/api/__tests__/teams.test.ts b/app/api/__tests__/teams.test.ts index 4f26d27..c84747a 100644 --- a/app/api/__tests__/teams.test.ts +++ b/app/api/__tests__/teams.test.ts @@ -24,7 +24,7 @@ jest.mock('@/lib/apiHelpers', () => ({ })); describe('/api/teams', () => { - let mockDb: any; + let mockDb: { all: jest.Mock }; beforeEach(() => { // Create mock database @@ -46,7 +46,7 @@ describe('/api/teams', () => { mockDb.all.mockResolvedValue(mockTeams); - const response = await GET(); + const _response = await GET(); expect(mockDb.all).toHaveBeenCalledWith( expect.stringContaining('SELECT * FROM') @@ -59,7 +59,7 @@ describe('/api/teams', () => { it('returns empty array when no teams exist', async () => { mockDb.all.mockResolvedValue([]); - const response = await GET(); + const _response = await GET(); const { createSuccessResponse } = require('@/lib/apiHelpers'); expect(createSuccessResponse).toHaveBeenCalledWith([]); @@ -69,7 +69,7 @@ describe('/api/teams', () => { const dbError = new Error('Table does not exist'); mockDb.all.mockRejectedValue(dbError); - const response = await GET(); + const _response = await GET(); const { createDatabaseError } = require('@/lib/apiHelpers'); expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError); @@ -80,7 +80,7 @@ describe('/api/teams', () => { const { getDatabase } = require('@/lib/database'); getDatabase.mockRejectedValue(connectionError); - const response = await GET(); + const _response = await GET(); const { createDatabaseError } = require('@/lib/apiHelpers'); expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError); diff --git a/app/api/addStream/route.ts b/app/api/addStream/route.ts index 940fcfe..750962e 100644 --- a/app/api/addStream/route.ts +++ b/app/api/addStream/route.ts @@ -1,24 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; -import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher } from '../../../lib/obsClient'; +import { connectToOBS, getOBSClient, disconnectFromOBS, addSourceToSwitcher, createGroupIfNotExists, addSourceToGroup } from '../../../lib/obsClient'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '../../../lib/constants'; interface OBSClient { call: (method: string, params?: Record) => Promise>; } -interface OBSScene { -sceneName: string; -} - interface OBSInput { inputName: string; } -interface GetSceneListResponse { -currentProgramSceneName: string; -currentPreviewSceneName: string; -scenes: OBSScene[]; -} interface GetInputListResponse { inputs: OBSInput[]; @@ -33,85 +28,38 @@ const screens = [ 'ss_bottom_right', ]; -async function fetchTeamName(teamId: number) { +async function fetchTeamInfo(teamId: number) { + const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); try { - const baseUrl = process.env.BASE_URL || 'http://localhost:3000'; - const response = await fetch(`${baseUrl}/api/getTeamName?team_id=${teamId}`); - if (!response.ok) { - throw new Error('Failed to fetch team name'); - } - 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, - }, + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, }); - 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 + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' }); - console.log(`Audio rerouted for "${inputName}".`); + const teamInfo = await db.get( + `SELECT team_name, group_name FROM ${teamsTableName} WHERE team_id = ?`, + [teamId] + ); - // Step 4: Mute the input - await obs.call('SetInputMute', { - inputName, - inputMuted: true, - }); + 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; + } +} - 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'; @@ -160,20 +108,23 @@ export async function POST(request: NextRequest) { } throw new Error('GetInputList failed.'); } - const teamName = await fetchTeamName(team_id); - console.log('Team Name:', teamName) - const response = await obs.call('GetSceneList'); - 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 }); + + const teamInfo = await fetchTeamInfo(team_id); + if (!teamInfo) { + throw new Error('Team not found'); } + console.log('Team Info:', teamInfo); + + // Use group_name if it exists, otherwise use team_name + const groupName = teamInfo.group_name || teamInfo.team_name; + const sourceExists = inputs.some((input: OBSInput) => input.inputName === obs_source_name); if (!sourceExists) { - await addBrowserSourceWithAudioControl(obs, teamName, obs_source_name, url) + // Create/ensure group exists and add source to it + await createGroupIfNotExists(groupName); + await addSourceToGroup(groupName, obs_source_name, url); console.log(`OBS source "${obs_source_name}" created.`); @@ -196,7 +147,12 @@ export async function POST(request: NextRequest) { } const db = await getDatabase(); - const query = `INSERT INTO streams_2025_spring_adr (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`; + const streamsTableName = getTableName(BASE_TABLE_NAMES.STREAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + const query = `INSERT INTO ${streamsTableName} (name, obs_source_name, url, team_id) VALUES (?, ?, ?, ?)`; db.run(query, [name, obs_source_name, url, team_id]) await disconnectFromOBS(); return NextResponse.json({ message: 'Stream added successfully' }, {status: 201}) diff --git a/app/api/createGroup/route.ts b/app/api/createGroup/route.ts new file mode 100644 index 0000000..10c3c44 --- /dev/null +++ b/app/api/createGroup/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants'; +import { validateInteger } from '@/lib/security'; + +const { createGroupIfNotExists } = require('@/lib/obsClient'); + +const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { teamId, groupName } = body; + + // Validate input + if (!teamId || !groupName) { + return NextResponse.json({ error: 'Team ID and group name are required' }, { status: 400 }); + } + + const validTeamId = validateInteger(teamId); + if (!validTeamId) { + return NextResponse.json({ error: 'Invalid team ID' }, { status: 400 }); + } + + // Sanitize group name (only allow alphanumeric, spaces, dashes, underscores) + const sanitizedGroupName = groupName.replace(/[^a-zA-Z0-9\s\-_]/g, ''); + if (!sanitizedGroupName || sanitizedGroupName.length === 0) { + return NextResponse.json({ error: 'Invalid group name' }, { status: 400 }); + } + + // Open database connection + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + // Update team with group name + await db.run( + `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, + [sanitizedGroupName, validTeamId] + ); + + // Create group in OBS + const result = await createGroupIfNotExists(sanitizedGroupName); + + await db.close(); + + return NextResponse.json({ + success: true, + message: 'Group created/updated successfully', + groupName: sanitizedGroupName, + obsResult: result + }); + } catch (error) { + console.error('Error creating group:', error); + return NextResponse.json({ error: 'Failed to create group' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/streams/[id]/route.ts b/app/api/streams/[id]/route.ts index 89cdb70..1a8974e 100644 --- a/app/api/streams/[id]/route.ts +++ b/app/api/streams/[id]/route.ts @@ -1,6 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '../../../../lib/database'; import { TABLE_NAMES } from '../../../../lib/constants'; +import { getOBSClient } from '../../../../lib/obsClient'; + +interface OBSInput { + inputName: string; + inputUuid: string; +} + +interface GetInputListResponse { + inputs: OBSInput[]; +} // GET single stream export async function GET( @@ -106,7 +116,38 @@ export async function DELETE( ); } - // Delete stream + // Try to delete from OBS first + 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( `DELETE FROM ${TABLE_NAMES.STREAMS} WHERE id = ?`, [resolvedParams.id] diff --git a/app/api/syncGroups/route.ts b/app/api/syncGroups/route.ts new file mode 100644 index 0000000..7e0a5c4 --- /dev/null +++ b/app/api/syncGroups/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '@/lib/constants'; + +const { createGroupIfNotExists } = require('@/lib/obsClient'); + +const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + +export async function POST() { + try { + // Open database connection + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + // Get all teams without groups + const teamsWithoutGroups = await db.all( + `SELECT team_id, team_name FROM ${teamsTableName} WHERE group_name IS NULL` + ); + + const syncResults = []; + + for (const team of teamsWithoutGroups) { + try { + // Create group in OBS using team name + const obsResult = await createGroupIfNotExists(team.team_name); + + // Update database with group name + await db.run( + `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, + [team.team_name, team.team_id] + ); + + syncResults.push({ + teamId: team.team_id, + teamName: team.team_name, + groupName: team.team_name, + success: true, + obsResult + }); + } catch (error) { + console.error(`Error syncing team ${team.team_id}:`, error); + syncResults.push({ + teamId: team.team_id, + teamName: team.team_name, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + await db.close(); + + const successCount = syncResults.filter(r => r.success).length; + const failureCount = syncResults.filter(r => !r.success).length; + + return NextResponse.json({ + success: true, + message: `Sync completed: ${successCount} successful, ${failureCount} failed`, + results: syncResults, + summary: { + total: syncResults.length, + successful: successCount, + failed: failureCount + } + }); + } catch (error) { + console.error('Error syncing groups:', error); + return NextResponse.json({ error: 'Failed to sync groups' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index ce99d57..36addd1 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -1,4 +1,3 @@ -import { NextResponse } from 'next/server'; import { getDatabase } from '../../../lib/database'; import { Team } from '@/types'; import { TABLE_NAMES } from '@/lib/constants'; @@ -39,14 +38,14 @@ function validateTeamInput(data: unknown): { return { valid: true, - data: { team_name: team_name.trim() } + data: { team_name: (team_name as string).trim() } }; } export const GET = withErrorHandling(async () => { try { const db = await getDatabase(); - const teams: Team[] = await db.all(`SELECT * FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); + const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); return createSuccessResponse(teams); } catch (error) { @@ -86,7 +85,8 @@ export const POST = withErrorHandling(async (request: Request) => { const newTeam: Team = { team_id: result.lastID!, - team_name: team_name + team_name: team_name, + group_name: null }; return createSuccessResponse(newTeam, 201); diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 6fe9463..4422a66 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -86,7 +86,7 @@ export default function EditStream() { if (streamId) { fetchData(); } - }, [streamId]); + }, [streamId, showError]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/app/globals.css b/app/globals.css index 955ad62..288b6ab 100644 --- a/app/globals.css +++ b/app/globals.css @@ -213,6 +213,10 @@ body { margin-top: 4px; overflow: hidden; 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 { diff --git a/app/page.tsx b/app/page.tsx index 2d18dfe..2dc242d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -35,7 +35,7 @@ export default function Home() { const activeSourceIds = useActiveSourceLookup(streams, activeSources); // Debounced API calls to prevent excessive requests - const debouncedSetActive = useDebounce(async (screen: ScreenType, id: number | null) => { + const setActiveFunction = useCallback(async (screen: ScreenType, id: number | null) => { if (id) { const selectedStream = streams.find(stream => stream.id === id); try { @@ -62,7 +62,9 @@ export default function Home() { })); } } - }, 300); + }, [streams, showError, showSuccess]); + + const debouncedSetActive = useDebounce(setActiveFunction, 300); const fetchData = useCallback(async () => { const endTimer = PerformanceMonitor.startTimer('fetchData'); diff --git a/app/streams/page.tsx b/app/streams/page.tsx index e92feeb..75aa281 100644 --- a/app/streams/page.tsx +++ b/app/streams/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import Dropdown from '@/components/Dropdown'; import { Team } from '@/types'; import { useToast } from '@/lib/useToast'; @@ -18,7 +18,7 @@ export default function AddStream() { const [formData, setFormData] = useState({ name: '', obs_source_name: '', - url: '', + twitch_username: '', team_id: null, }); const [teams, setTeams] = useState<{id: number; name: string}[]>([]); @@ -26,14 +26,11 @@ export default function AddStream() { const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); 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(); - // Fetch teams and streams on component mount - useEffect(() => { - fetchData(); - }, []); - - const fetchData = async () => { + const fetchData = useCallback(async () => { setIsLoading(true); try { const [teamsResponse, streamsResponse] = await Promise.all([ @@ -63,7 +60,13 @@ export default function AddStream() { } finally { setIsLoading(false); } - }; + }, [showError]); + + // Fetch teams and streams on component mount + useEffect(() => { + fetchData(); + }, [fetchData]); + const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -85,6 +88,32 @@ 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) => { e.preventDefault(); @@ -100,14 +129,10 @@ export default function AddStream() { errors.obs_source_name = 'OBS source name is required'; } - if (!formData.url.trim()) { - errors.url = 'Stream URL is required'; - } else { - try { - new URL(formData.url); - } catch { - errors.url = 'Please enter a valid URL'; - } + if (!formData.twitch_username.trim()) { + errors.twitch_username = 'Twitch username is required'; + } else if (!/^[a-zA-Z0-9_]{4,25}$/.test(formData.twitch_username.trim())) { + errors.twitch_username = 'Twitch username must be 4-25 characters and contain only letters, numbers, and underscores'; } if (!formData.team_id) { @@ -123,16 +148,21 @@ export default function AddStream() { setIsSubmitting(true); try { + const submissionData = { + ...formData, + url: `https://www.twitch.tv/${formData.twitch_username.trim()}` + }; + const response = await fetch('/api/addStream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData), + body: JSON.stringify(submissionData), }); const data = await response.json(); if (response.ok) { showSuccess('Stream Added', `"${formData.name}" has been added successfully`); - setFormData({ name: '', obs_source_name: '', url: '', team_id: null }); + setFormData({ name: '', obs_source_name: '', twitch_username: '', team_id: null }); setValidationErrors({}); fetchData(); } else { @@ -147,14 +177,15 @@ export default function AddStream() { }; return ( -
- {/* Title */} -
-

Streams

-

- Organize your content by creating and managing stream sources -

-
+ <> +
+ {/* Title */} +
+

Streams

+

+ Organize your content by creating and managing stream sources +

+
{/* Add New Stream */}
@@ -206,25 +237,25 @@ export default function AddStream() { )}
- {/* URL */} + {/* Twitch Username */}
- {validationErrors.url && ( + {validationErrors.twitch_username && (
- {validationErrors.url} + {validationErrors.twitch_username}
)}
@@ -235,7 +266,7 @@ export default function AddStream() { Team
-
+
Team: {team?.name || 'Unknown'}
-
+
ID: {stream.id}
- - View Stream - +
+ + + + + View Stream + + +
@@ -310,8 +360,51 @@ export default function AddStream() { )}
- {/* Toast Notifications */} - - + {/* Toast Notifications */} + + + + {/* Delete Confirmation Modal */} + {deleteConfirm && ( +
+
+

Confirm Deletion

+

+ Are you sure you want to delete the stream “{deleteConfirm.name}”? This will remove it from both the database and OBS. +

+
+ + +
+
+
+ )} + ); } \ No newline at end of file diff --git a/app/teams/page.tsx b/app/teams/page.tsx index 223e73a..bbbc38a 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -11,6 +11,8 @@ export default function Teams() { const [newTeamName, setNewTeamName] = useState(''); const [editingTeam, setEditingTeam] = useState(null); const [editingName, setEditingName] = useState(''); + const [creatingGroupForTeam, setCreatingGroupForTeam] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [updatingTeamId, setUpdatingTeamId] = useState(null); const [deletingTeamId, setDeletingTeamId] = useState(null); @@ -146,6 +148,64 @@ export default function Teams() { } }; + const handleSyncAllGroups = async () => { + if (!confirm('This will create OBS groups for all teams that don\'t have one. Continue?')) { + return; + } + + setIsSyncing(true); + try { + const res = await fetch('/api/syncGroups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (res.ok) { + const result = await res.json(); + fetchTeams(); + showSuccess('Groups Synced', `${result.summary.successful} groups created successfully`); + if (result.summary.failed > 0) { + showError('Some Failures', `${result.summary.failed} groups failed to create`); + } + } else { + const error = await res.json(); + showError('Failed to Sync Groups', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error syncing groups:', error); + showError('Failed to Sync Groups', 'Network error or server unavailable'); + } finally { + setIsSyncing(false); + } + }; + + const handleCreateGroup = async (teamId: number, teamName: string) => { + const groupName = prompt(`Enter group name for team "${teamName}":`, teamName); + if (!groupName) return; + + setCreatingGroupForTeam(teamId); + try { + const res = await fetch('/api/createGroup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ teamId, groupName }), + }); + + if (res.ok) { + fetchTeams(); + showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`); + } else { + const error = await res.json(); + showError('Failed to Create Group', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error creating group:', error); + showError('Failed to Create Group', 'Network error or server unavailable'); + } finally { + setCreatingGroupForTeam(null); + } + }; + const startEditing = (team: Team) => { setEditingTeam(team); setEditingName(team.team_name); @@ -209,7 +269,18 @@ export default function Teams() { {/* Teams List */}
-

Existing Teams

+
+

Existing Teams

+ +
{isLoading ? (
@@ -227,7 +298,7 @@ export default function Teams() { ) : (
{teams.map((team) => ( -
+
{editingTeam?.team_id === team.team_id ? (
{team.team_name}
ID: {team.team_id}
+ {team.group_name ? ( +
OBS Group: {team.group_name}
+ ) : ( +
No OBS Group
+ )}
+ {!team.group_name && ( + + )} - - {(controlledIsOpen ?? isOpen) && ( -
- {options.length === 0 ? ( -
- No streams available -
- ) : ( - options.map((option) => ( -
handleSelect(option)} - className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`} - > - {option.name} -
- )) - )} + const dropdownMenu = (controlledIsOpen ?? isOpen) && mounted ? ( +
+ {options.length === 0 ? ( +
+ No teams available
+ ) : ( + options.map((option) => ( +
handleSelect(option)} + className={`dropdown-item ${activeOption?.id === option.id ? 'active' : ''}`} + > + {option.name} +
+ )) )}
+ ) : null; + + return ( + <> +
+ +
+ + {mounted && typeof document !== 'undefined' && dropdownMenu ? + createPortal(dropdownMenu, document.body) : null + } + ); } \ No newline at end of file diff --git a/components/__tests__/ErrorBoundary.test.tsx b/components/__tests__/ErrorBoundary.test.tsx index 170521f..4459422 100644 --- a/components/__tests__/ErrorBoundary.test.tsx +++ b/components/__tests__/ErrorBoundary.test.tsx @@ -10,7 +10,7 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { }; // Mock window.location.reload using jest.spyOn -const mockReload = jest.fn(); +// const mockReload = jest.fn(); // Defined but not used in current tests describe('ErrorBoundary', () => { // 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', () => { // Skip this test in jsdom environment as window.location.reload cannot be easily mocked // In a real browser environment, this would work as expected - const originalReload = window.location.reload; + // const originalReload = window.location.reload; // Not used in jsdom test // Simple workaround for jsdom limitation if (typeof window.location.reload !== 'function') { @@ -119,11 +119,17 @@ describe('ErrorBoundary', () => { const originalEnv = process.env.NODE_ENV; beforeAll(() => { - process.env.NODE_ENV = 'development'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + writable: true + }); }); afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('shows error details in development mode', () => { @@ -147,11 +153,17 @@ describe('ErrorBoundary', () => { const originalEnv = process.env.NODE_ENV; beforeAll(() => { - process.env.NODE_ENV = 'production'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', + writable: true + }); }); afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('hides error details in production mode', () => { diff --git a/components/__tests__/Toast.test.tsx b/components/__tests__/Toast.test.tsx index d277e6c..a5dbbee 100644 --- a/components/__tests__/Toast.test.tsx +++ b/components/__tests__/Toast.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { ToastComponent, ToastContainer, Toast, ToastType } from '../Toast'; // Mock timer functions diff --git a/docs/OBS_SETUP.md b/docs/OBS_SETUP.md new file mode 100644 index 0000000..c4cc603 --- /dev/null +++ b/docs/OBS_SETUP.md @@ -0,0 +1,99 @@ +# OBS Source Switcher Setup Guide + +This document explains how to configure OBS Studio to work with the Source Switcher Plugin UI. + +## Prerequisites + +1. OBS Studio installed +2. [OBS WebSocket plugin](https://github.com/obsproject/obs-websocket) (usually included with OBS 28+) +3. [OBS Source Switcher plugin](https://obsproject.com/forum/resources/source-switcher.1090/) installed + +## Required Source Switcher Names + +You must create **exactly 7 Source Switcher sources** in OBS with these specific names: + +| Source Switcher Name | Screen Position | Text File | +|---------------------|-----------------|-----------| +| `ss_large` | Main/Large screen | `large.txt` | +| `ss_left` | Left screen | `left.txt` | +| `ss_right` | Right screen | `right.txt` | +| `ss_top_left` | Top left corner | `topLeft.txt` | +| `ss_top_right` | Top right corner | `topRight.txt` | +| `ss_bottom_left` | Bottom left corner | `bottomLeft.txt` | +| `ss_bottom_right` | Bottom right corner | `bottomRight.txt` | + +## Setup Instructions + +### 1. Configure OBS WebSocket + +1. In OBS, go to **Tools → WebSocket Server Settings** +2. Enable the WebSocket server +3. Set a port (default: 4455) +4. Optionally set a password +5. Note these settings for your `.env.local` file + +### 2. Create Source Switcher Sources + +For each screen position: + +1. In OBS, click the **+** button in Sources +2. Select **Source Switcher** +3. Name it exactly as shown in the table above (e.g., `ss_large`) +4. Configure the Source Switcher: + - **Mode**: Text File + - **File Path**: Point to the corresponding text file in your `files` directory + - **Switch Behavior**: Choose your preferred transition + +### 3. Configure Text File Monitoring + +Each Source Switcher should monitor its corresponding text file: + +- `ss_large` → monitors `{FILE_DIRECTORY}/large.txt` +- `ss_left` → monitors `{FILE_DIRECTORY}/left.txt` +- etc. + +Where `{FILE_DIRECTORY}` is the path configured in your `.env.local` file (default: `./files`) + +### 4. Add Browser Sources + +When you add streams through the UI, browser sources are automatically created in OBS with these settings: +- **Width**: 1600px +- **Height**: 900px +- **Audio**: Controlled via OBS (muted by default) + +## How It Works + +1. **Stream Selection**: When you select a stream for a screen position in the UI +2. **File Update**: The app writes the OBS source name to the corresponding text file +3. **Source Switch**: The Source Switcher detects the file change and switches to that source +4. **Group Organization**: Streams are organized into OBS groups based on their teams + +## Troubleshooting + +### Source Switcher not switching +- Verify the text file path is correct +- Check that the file is being updated (manually open the .txt file) +- Ensure Source Switcher is set to "Text File" mode + +### Sources not appearing +- Check OBS WebSocket connection in the footer +- Verify WebSocket credentials in `.env.local` +- Ensure the source name doesn't already exist in OBS + +### Missing screen positions +- Verify all 7 Source Switchers are created with exact names +- Check for typos in source names (they must match exactly) + +## Environment Variables + +Configure these in your `.env.local` file: + +```env +# OBS WebSocket Settings +OBS_WEBSOCKET_HOST=127.0.0.1 +OBS_WEBSOCKET_PORT=4455 +OBS_WEBSOCKET_PASSWORD=your_password_here + +# File Directory (where text files are stored) +FILE_DIRECTORY=./files +``` \ No newline at end of file diff --git a/files/SaT.json b/files/SaT.json new file mode 100644 index 0000000..d898caf --- /dev/null +++ b/files/SaT.json @@ -0,0 +1,998 @@ +{ + "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": {} + } + ] +} \ No newline at end of file diff --git a/lib/__tests__/apiHelpers.test.ts b/lib/__tests__/apiHelpers.test.ts index 8b51764..9bb97b4 100644 --- a/lib/__tests__/apiHelpers.test.ts +++ b/lib/__tests__/apiHelpers.test.ts @@ -32,7 +32,7 @@ describe('apiHelpers', () => { describe('createErrorResponse', () => { it('creates error response with default status 500', () => { - const response = createErrorResponse('Test Error'); + const _response = createErrorResponse('Test Error'); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -44,7 +44,7 @@ describe('apiHelpers', () => { }); 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.objectContaining({ @@ -81,7 +81,7 @@ describe('apiHelpers', () => { describe('createSuccessResponse', () => { it('creates success response with default status 200', () => { const data = { test: 'data' }; - const response = createSuccessResponse(data); + const _response = createSuccessResponse(data); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -95,7 +95,7 @@ describe('apiHelpers', () => { it('creates success response with custom status', () => { const data = { id: 1, name: 'test' }; - const response = createSuccessResponse(data, 201); + const _response = createSuccessResponse(data, 201); expect(NextResponse.json).toHaveBeenCalledWith( expect.objectContaining({ @@ -151,9 +151,9 @@ describe('apiHelpers', () => { }); describe('parseRequestBody', () => { - const mockRequest = (body: any): Request => ({ + const mockRequest = (body: unknown): Request => ({ json: jest.fn().mockResolvedValue(body), - } as any); + } as unknown as Request); it('parses valid JSON body without validator', async () => { const body = { name: 'test', value: 123 }; @@ -170,7 +170,7 @@ describe('apiHelpers', () => { it('handles invalid JSON', async () => { const request = { json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), - } as any; + } as unknown as Request; const result = await parseRequestBody(request); @@ -218,11 +218,17 @@ describe('apiHelpers', () => { const originalEnv = process.env.NODE_ENV; afterAll(() => { - process.env.NODE_ENV = originalEnv; + Object.defineProperty(process.env, 'NODE_ENV', { + value: originalEnv, + writable: true + }); }); it('includes error details in development', () => { - process.env.NODE_ENV = 'development'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', + writable: true + }); const originalError = new Error('Test error'); createDatabaseError('test operation', originalError); @@ -236,7 +242,10 @@ describe('apiHelpers', () => { }); it('excludes error details in production', () => { - process.env.NODE_ENV = 'production'; + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', + writable: true + }); const originalError = new Error('Test error'); createDatabaseError('test operation', originalError); diff --git a/lib/__tests__/useToast.test.ts b/lib/__tests__/useToast.test.ts index 8d96eec..991b621 100644 --- a/lib/__tests__/useToast.test.ts +++ b/lib/__tests__/useToast.test.ts @@ -178,7 +178,7 @@ describe('useToast', () => { it('returns unique IDs for each toast', () => { const { result } = renderHook(() => useToast()); - let id1: string, id2: string; + let id1: string = '', id2: string = ''; act(() => { // Mock different random values for unique IDs diff --git a/lib/apiClient.ts b/lib/apiClient.ts index a388761..5d386c7 100644 --- a/lib/apiClient.ts +++ b/lib/apiClient.ts @@ -18,7 +18,7 @@ export async function apiCall(url: string, options: RequestInit = {}): Promise = { 'Content-Type': 'application/json', - ...options.headers, + ...(options.headers as Record || {}), }; // Add API key if available diff --git a/lib/apiHelpers.ts b/lib/apiHelpers.ts index a85150d..d6c5687 100644 --- a/lib/apiHelpers.ts +++ b/lib/apiHelpers.ts @@ -140,7 +140,7 @@ export async function parseRequestBody( } return { success: true, data: body as T }; - } catch (error) { + } catch (_error) { return { success: false, response: createErrorResponse( diff --git a/lib/obsClient.js b/lib/obsClient.js index 4a6d673..5465302 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -120,48 +120,74 @@ async function addSourceToSwitcher(inputName, newSources) { } } -// async function addSourceToGroup(obs, teamName, obs_source_name, url) { -// try { -// // Step 1: Check if the group exists -// const { scenes } = await obs.call('GetSceneList'); -// const groupExists = scenes.some((scene) => scene.sceneName === teamName); +async function createGroupIfNotExists(groupName) { + try { + const obsClient = await getOBSClient(); + + // Check if the group (scene) exists + const { scenes } = await obsClient.call('GetSceneList'); + const groupExists = scenes.some((scene) => scene.sceneName === groupName); -// // Step 2: Create the group if it doesn't exist -// if (!groupExists) { -// console.log(`Group "${teamName}" does not exist. Creating it.`); -// await obs.call('CreateScene', { sceneName: teamName }); -// } else { -// console.log(`Group "${teamName}" already exists.`); -// } + if (!groupExists) { + console.log(`Creating group "${groupName}"`); + await obsClient.call('CreateScene', { sceneName: groupName }); + return { created: true, message: `Group "${groupName}" created successfully` }; + } else { + console.log(`Group "${groupName}" already exists`); + return { created: false, message: `Group "${groupName}" already exists` }; + } + } catch (error) { + console.error('Error creating group:', error.message); + throw error; + } +} -// // Step 3: Add the source to the group -// console.log(`Adding source "${obs_source_name}" to group "${teamName}".`); -// await obs.call('CreateInput', { -// sceneName: teamName, -// inputName: obs_source_name, -// inputKind: 'browser_source', -// inputSettings: { -// width: 1600, -// height: 900, -// url, -// control_audio: true, -// }, -// }); - -// // Step 4: Enable "Control audio via OBS" -// await obs.call('SetInputSettings', { -// inputName: obs_source_name, -// inputSettings: { -// control_audio: true, // Enable audio control -// }, -// overlay: true, // Keep existing settings and apply changes -// }); - -// console.log(`Source "${obs_source_name}" successfully added to group "${teamName}".`); -// } catch (error) { -// console.error('Error adding source to group:', error.message); -// } -// } +async function addSourceToGroup(groupName, sourceName, url) { + try { + const obsClient = await getOBSClient(); + + // Ensure group exists + await createGroupIfNotExists(groupName); + + // Check if source already exists in the group + const { sceneItems } = await obsClient.call('GetSceneItemList', { sceneName: groupName }); + const sourceExists = sceneItems.some(item => item.sourceName === sourceName); + + if (!sourceExists) { + // Create the browser source in the group + console.log(`Adding source "${sourceName}" to group "${groupName}"`); + await obsClient.call('CreateInput', { + sceneName: groupName, + inputName: sourceName, + inputKind: 'browser_source', + inputSettings: { + width: 1600, + height: 900, + url, + control_audio: true, + }, + }); + + // 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 @@ -171,5 +197,7 @@ module.exports = { disconnectFromOBS, addSourceToSwitcher, ensureConnected, - getConnectionStatus + getConnectionStatus, + createGroupIfNotExists, + addSourceToGroup }; \ No newline at end of file diff --git a/lib/performance.ts b/lib/performance.ts index 8643f0c..c523293 100644 --- a/lib/performance.ts +++ b/lib/performance.ts @@ -3,11 +3,12 @@ import React, { useMemo, useCallback, useRef } from 'react'; // Debounce hook for preventing excessive API calls +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function useDebounce any>( callback: T, delay: number ): T { - const timeoutRef = useRef(); + const timeoutRef = useRef(null); return useCallback((...args: Parameters) => { if (timeoutRef.current) { @@ -21,7 +22,7 @@ export function useDebounce any>( } // Throttle hook for limiting function calls -export function useThrottle any>( +export function useThrottle unknown>( callback: T, delay: number ): T { @@ -38,16 +39,21 @@ export function useThrottle any>( // Memoized stream lookup utilities export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) { + const sourceToIdMap = new Map(); + const idToStreamMap = new Map(); + + 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(() => { - const sourceToIdMap = new Map(); - const idToStreamMap = new Map(); - - streams.forEach(stream => { - sourceToIdMap.set(stream.obs_source_name, stream.id); - idToStreamMap.set(stream.id, stream); - }); - - return { sourceToIdMap, idToStreamMap }; + return createStreamLookupMaps(streams); }, [streams]); } @@ -56,7 +62,7 @@ export function useActiveSourceLookup( streams: Array<{ id: number; obs_source_name: string; name: string }>, activeSources: Record ) { - const { sourceToIdMap } = createStreamLookupMaps(streams); + const { sourceToIdMap } = useStreamLookupMaps(streams); return useMemo(() => { const activeSourceIds: Record = {}; @@ -104,7 +110,7 @@ export class PerformanceMonitor { } static getAllMetrics() { - const result: Record = {}; + const result: Record> = {}; this.metrics.forEach((_, label) => { result[label] = this.getMetrics(label); }); @@ -155,11 +161,11 @@ export function usePageVisibility() { export function useSmartPolling( callback: () => void | Promise, interval: number, - dependencies: any[] = [] + dependencies: unknown[] = [] ) { const isVisible = usePageVisibility(); const callbackRef = useRef(callback); - const intervalRef = useRef(); + const intervalRef = useRef(null); // Update callback ref React.useEffect(() => { @@ -167,22 +173,24 @@ export function useSmartPolling( }, [callback]); React.useEffect(() => { + // Clear any existing interval before setting up a new one + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (isVisible) { // Start polling when visible callbackRef.current(); intervalRef.current = setInterval(() => { callbackRef.current(); }, interval); - } else { - // Stop polling when not visible - if (intervalRef.current) { - clearInterval(intervalRef.current); - } } return () => { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } }; }, [interval, isVisible, ...dependencies]); diff --git a/lib/security.ts b/lib/security.ts index e25f00d..e38cecf 100644 --- a/lib/security.ts +++ b/lib/security.ts @@ -18,7 +18,15 @@ export function isValidUrl(url: string): boolean { } export function isPositiveInteger(value: unknown): value is number { - return Number.isInteger(value) && value > 0; + return Number.isInteger(value) && Number(value) > 0; +} + +export function validateInteger(value: unknown): number | null { + const num = Number(value); + if (Number.isInteger(num) && num > 0) { + return num; + } + return null; } // String sanitization diff --git a/middleware.ts b/middleware.ts index 910dd00..10f2ea0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -21,7 +21,10 @@ export function middleware(request: NextRequest) { // Skip authentication for localhost/internal requests (optional security) const host = request.headers.get('host'); if (host && (host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('192.168.'))) { - console.log('Allowing internal network access without API key'); + // Don't log for frequently polled endpoints to reduce noise + if (!request.nextUrl.pathname.includes('/api/obsStatus')) { + console.log('Allowing internal network access without API key'); + } return NextResponse.next(); } diff --git a/scripts/addGroupNameToTeams.ts b/scripts/addGroupNameToTeams.ts new file mode 100644 index 0000000..8a24fba --- /dev/null +++ b/scripts/addGroupNameToTeams.ts @@ -0,0 +1,57 @@ +import sqlite3 from 'sqlite3'; +import { open } from 'sqlite'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '../lib/constants'; + +const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + +const addGroupNameToTeams = async () => { + try { + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + + // Open database connection + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + console.log('Database connection established.'); + + // Generate table name for teams + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + console.log(`Adding group_name column to ${teamsTableName}`); + + // Check if column already exists + const tableInfo = await db.all(`PRAGMA table_info(${teamsTableName})`); + const hasGroupName = tableInfo.some(col => col.name === 'group_name'); + + if (!hasGroupName) { + // Add group_name column + await db.exec(` + ALTER TABLE ${teamsTableName} + ADD COLUMN group_name TEXT + `); + + console.log(`✅ Added group_name column to ${teamsTableName}`); + } else { + console.log(`â„šī¸ group_name column already exists in ${teamsTableName}`); + } + + // Close database connection + await db.close(); + console.log('Database connection closed.'); + console.log('✅ Successfully updated teams table schema!'); + + } catch (error) { + console.error('Error updating table:', error); + process.exit(1); + } +}; + +// Run the script +addGroupNameToTeams(); \ No newline at end of file diff --git a/scripts/createSatSummer2025Tables.ts b/scripts/createSatSummer2025Tables.ts index d2324bb..c60ec68 100644 --- a/scripts/createSatSummer2025Tables.ts +++ b/scripts/createSatSummer2025Tables.ts @@ -60,7 +60,8 @@ const createSatSummer2025Tables = async () => { await db.exec(` CREATE TABLE IF NOT EXISTS ${teamsTableName} ( team_id INTEGER PRIMARY KEY, - team_name TEXT NOT NULL + team_name TEXT NOT NULL, + group_name TEXT ) `); diff --git a/tsconfig.json b/tsconfig.json index 5e7c74b..5a35e12 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js", "types/**/*.d.ts"], "exclude": ["node_modules"] } diff --git a/types/index.ts b/types/index.ts index 1ba7870..fcb17f1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -14,4 +14,5 @@ export type Screen = { export type Team = { team_id: number; team_name: string; + group_name?: string | null; }; \ No newline at end of file diff --git a/types/jest-dom.d.ts b/types/jest-dom.d.ts new file mode 100644 index 0000000..56fa56b --- /dev/null +++ b/types/jest-dom.d.ts @@ -0,0 +1,10 @@ +// Jest DOM type definitions +import '@testing-library/jest-dom'; + +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production' | 'test'; + } + } +} \ No newline at end of file