diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..891dcf4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,40 @@
+# Git
+.git
+.gitignore
+
+# IDE
+.vs
+.vscode
+.idea
+*.user
+*.suo
+
+# Build outputs
+**/bin
+**/obj
+**/dist
+**/node_modules
+
+# Test outputs
+**/TestResults
+**/coverage
+
+# Docker data (don't include local data in builds)
+docker-data
+docker-logs
+
+# Documentation
+*.md
+!README.md
+
+# Misc
+*.log
+.DS_Store
+Thumbs.db
+
+# Facebook Automation (not needed for main app)
+FacebookAutomation
+
+# CDP Test App (not needed for main app)
+CDPTestApp
+
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..659c4b2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,87 @@
+# =============================================================================
+# Thrive Stream Controller - Environment Configuration
+# =============================================================================
+#
+# SETUP INSTRUCTIONS:
+# 1. Copy this file to `.env` in the same directory
+# 2. Fill in your actual values below
+# 3. NEVER commit the `.env` file to source control
+#
+# To use: docker-compose up -d
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# OBS WebSocket Configuration
+# -----------------------------------------------------------------------------
+# The container connects to OBS running on your host machine.
+# Default OBS WebSocket port is 4455.
+
+# OBS WebSocket URL (use host.docker.internal to reach host from container)
+OBS__WebSocketUrl=ws://host.docker.internal:4455
+
+# OBS WebSocket password (leave empty if OBS has no password set)
+# Find this in OBS: Tools → WebSocket Server Settings
+OBS__Password=
+
+# -----------------------------------------------------------------------------
+# YouTube OAuth Configuration
+# -----------------------------------------------------------------------------
+# Get these from Google Cloud Console:
+# https://console.cloud.google.com/apis/credentials
+#
+# 1. Create a project (or select existing)
+# 2. Enable "YouTube Data API v3"
+# 3. Create OAuth 2.0 credentials (Web application type)
+# 4. Add authorized redirect URI: http://localhost:8080/api/auth/youtube/callback
+
+# YouTube OAuth Client ID (ends with .apps.googleusercontent.com)
+YouTube__ClientId=
+
+# YouTube OAuth Client Secret
+YouTube__ClientSecret=
+
+# OAuth Redirect URI (update port if you change the app port)
+YouTube__RedirectUri=http://localhost:8080/api/auth/youtube/callback
+
+# -----------------------------------------------------------------------------
+# Facebook Live Producer URL
+# -----------------------------------------------------------------------------
+# This is the URL volunteers will be directed to for the manual "Go Live" step.
+#
+# To find your Page's Live Producer URL:
+# 1. Go to your Facebook Page
+# 2. Click "Live Video" or go to facebook.com/live/producer
+# 3. Select your Page
+# 4. Copy the URL from your browser
+#
+# Example: https://www.facebook.com/live/producer?ref=OBS
+
+Facebook__LiveProducerUrl=https://www.facebook.com/live/producer
+
+# -----------------------------------------------------------------------------
+# Application Settings
+# -----------------------------------------------------------------------------
+
+# Environment (Production, Development)
+ASPNETCORE_ENVIRONMENT=Production
+
+# Database connection string (SQLite - stored in mounted volume)
+ConnectionStrings__DefaultConnection=Data Source=/app/data/thrivestream.db
+
+# =============================================================================
+# NOTES:
+# =============================================================================
+#
+# Double underscores (__) represent nested configuration in .NET.
+# For example: YouTube__ClientId maps to { "YouTube": { "ClientId": "..." } }
+#
+# After first run, you'll need to authorize YouTube in the app:
+# 1. Open http://localhost:8080 in your browser
+# 2. Go to Settings
+# 3. Click "Connect YouTube Account"
+# 4. Complete the OAuth flow
+#
+# The refresh token is stored encrypted in the database, so you only need
+# to authorize once (unless you revoke access).
+# =============================================================================
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..84eb068
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ push:
+ branches: [master, dev]
+ pull_request:
+ branches: [master]
+
+jobs:
+ build-ui:
+ name: Build UI
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: UI
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: UI/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ build-api:
+ name: Build & Test API
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: API
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
+
+ docker-build:
+ name: Docker Build Verification
+ runs-on: ubuntu-latest
+ needs: [build-ui, build-api]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ tags: thrive-stream-controller:ci-test
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ provenance: false
+ sbom: false
+
diff --git a/.gitignore b/.gitignore
index 9a5aced..e0db49e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,10 @@
# Logs
-logs
+*logs/
*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
+*npm-debug.log*
+*yarn-debug.log*
+*yarn-error.log*
+*lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
@@ -19,45 +19,46 @@ pids
lib-cov
# Coverage directory used by tools like istanbul
-coverage
+*coverage/
*.lcov
# nyc test coverage
-.nyc_output
+*.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
+*.grunt
# Bower dependency directory (https://bower.io/)
-bower_components
+*bower_components/
# node-waf configuration
-.lock-wscript
+*.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
+*build/Release
# Dependency directories
-node_modules/
-jspm_packages/
+*node_modules/
+*jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
+*web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
-.npm
+*.npm
+*.npmrc
# Optional eslint cache
-.eslintcache
+*.eslintcache
# Optional stylelint cache
-.stylelintcache
+*.stylelintcache
# Optional REPL history
-.node_repl_history
+*.node_repl_history
# Output of 'npm pack'
*.tgz
@@ -66,24 +67,25 @@ web_modules/
.yarn-integrity
# dotenv environment variable files
-.env
-.env.*
+*.env
+*.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
-.cache
-.parcel-cache
+*.cache
+*.parcel-cache
# Next.js build output
-.next
-out
+*.next
+*out/
# Nuxt.js build / generate output
-.nuxt
-dist
+*.nuxt
+*dist/
+*.output
# Gatsby files
-.cache/
+*.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
@@ -92,8 +94,8 @@ dist
.vuepress/dist
# vuepress v2.x temp and cache directory
-.temp
-.cache
+*.temp
+*.cache
# Sveltekit cache directory
.svelte-kit/
@@ -126,14 +128,84 @@ dist
.vscode-test
# yarn v3
-.pnp.*
-.yarn/*
+*.pnp.*
+*.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
-# Vite logs files
-vite.config.js.timestamp-*
-vite.config.ts.timestamp-*
+# Vite files
+*vite.config.js.timestamp-*
+*vite.config.ts.timestamp-*
+*.vite/
+
+# dotnet files
+## A streamlined .gitignore for modern .NET projects
+## including temporary files, build results, and
+## files generated by popular .NET tools. If you are
+## developing with Visual Studio, the VS .gitignore
+## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+## has more thorough IDE-specific entries.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore
+
+# Build results
+*[Dd]ebug/
+*[Dd]ebugPublic/
+*[Rr]elease/
+*[Rr]eleases/
+*x64/
+*x86/
+*[Ww][Ii][Nn]32/
+*[Aa][Rr][Mm]/
+*[Aa][Rr][Mm]64/
+*bld/
+*[Bb]in/
+*[Oo]bj/
+*[Ll]og/
+*[Ll]ogs/
+
+# .NET Core
+*project.lock.json
+*project.fragment.lock.json
+*artifacts/
+*.vs
+
+# ASP.NET Scaffolding
+*ScaffoldingReadMe.txt
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+
+# Others
+~$*
+*~
+*CodeCoverage/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# MSTest test Results
+*[Tt]est[Rr]esult*/
+*[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+*TestResult.xml
+*nunit-*.xml
+
+# DB files
+*.db
+*.db-wal
+*.db-shm
+*.user
+
+*.http
+
+# Docker runtime data
+docker-data/
+docker-logs/
diff --git a/API/README.md b/API/README.md
new file mode 100644
index 0000000..f86d7a8
--- /dev/null
+++ b/API/README.md
@@ -0,0 +1,112 @@
+# Thrive Stream Controller API
+
+## Configuration
+
+### OBS WebSocket Password
+
+The API needs to connect to OBS Studio via WebSocket. You can configure the password in several ways:
+
+#### Option 1: appsettings.Development.json (Recommended for Development)
+
+Edit `ThriveStreamController.API/appsettings.Development.json`:
+
+```json
+{
+ "OBS": {
+ "Password": "your-obs-password-here"
+ }
+}
+```
+
+**Note:** This file is gitignored by default, so your password won't be committed to source control.
+
+#### Option 2: User Secrets (Alternative for Development)
+
+```bash
+cd ThriveStreamController.API
+dotnet user-secrets set "OBS:Password" "your-obs-password-here"
+```
+
+User secrets are stored outside the project directory and are never committed to source control.
+
+#### Option 3: appsettings.json (For Production/Deployment)
+
+Edit `ThriveStreamController.API/appsettings.json`:
+
+```json
+{
+ "OBS": {
+ "WebSocketUrl": "ws://localhost:4455",
+ "Password": "your-obs-password-here"
+ }
+}
+```
+
+**Warning:** Be careful not to commit sensitive passwords to source control. Use environment variables or a secure configuration provider in production.
+
+#### Option 4: Environment Variables (For Production)
+
+Set the environment variable:
+
+```bash
+# Windows PowerShell
+$env:OBS__Password = "your-obs-password-here"
+
+# Windows CMD
+set OBS__Password=your-obs-password-here
+
+# Linux/Mac
+export OBS__Password="your-obs-password-here"
+```
+
+**Note:** Use double underscores (`__`) to represent nested configuration keys.
+
+### OBS Studio Setup
+
+1. Open OBS Studio
+2. Go to **Tools → WebSocket Server Settings**
+3. Enable the WebSocket server
+4. Set the port to **4455** (default)
+5. Set a password (or leave blank for no password)
+6. Click **Apply** and **OK**
+
+### Running the API
+
+```bash
+# From the repository root
+dotnet run --project API/ThriveStreamController.API/ThriveStreamController.API.csproj
+
+# Or from the API directory
+cd API
+dotnet run --project ThriveStreamController.API/ThriveStreamController.API.csproj
+```
+
+The API will start on `http://localhost:5080`
+
+### Testing the Connection
+
+Once the API is running, you can test the OBS connection:
+
+1. Open the UI at `http://localhost:5173`
+2. The UI should show "Backend Connection: Connected"
+3. Click "Connect to OBS"
+4. If configured correctly, you should see "OBS Studio: Connected"
+
+### Troubleshooting
+
+**"Failed to connect to OBS"**
+- Ensure OBS Studio is running
+- Verify the WebSocket server is enabled in OBS (Tools → WebSocket Server Settings)
+- Check that the password in your configuration matches the OBS WebSocket password
+- Verify the port is 4455 (or update `appsettings.json` if using a different port)
+
+**"Backend Connection: Disconnected"**
+- Ensure the API is running on port 5080
+- Check the API logs for errors
+- Verify CORS is configured correctly (should allow `http://localhost:5173`)
+
+**"ObjectDisposedException" or "IDisposable" errors**
+- These have been fixed in the latest version
+- Make sure you're running the latest code
+- Restart the API if you see these errors
+
diff --git a/API/ThriveStreamController.API/Controllers/OBSController.cs b/API/ThriveStreamController.API/Controllers/OBSController.cs
new file mode 100644
index 0000000..41a580d
--- /dev/null
+++ b/API/ThriveStreamController.API/Controllers/OBSController.cs
@@ -0,0 +1,440 @@
+using Microsoft.AspNetCore.Mvc;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Core.Models.Requests;
+using ThriveStreamController.Core.System;
+
+namespace ThriveStreamController.API.Controllers
+{
+ ///
+ /// Controller for OBS WebSocket operations.
+ /// Provides endpoints for connecting, disconnecting, and controlling OBS Studio.
+ ///
+ [ApiController]
+ [Route("api/[controller]")]
+ public class OBSController : ControllerBase
+ {
+ private readonly IOBSService _obsService;
+ private readonly ILogger _logger;
+ private readonly IConfiguration _configuration;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The OBS service instance.
+ /// The logger instance.
+ /// The configuration instance.
+ public OBSController(
+ IOBSService obsService,
+ ILogger logger,
+ IConfiguration configuration)
+ {
+ _obsService = obsService;
+ _logger = logger;
+ _configuration = configuration;
+ }
+
+ ///
+ /// Gets the current OBS connection status.
+ ///
+ /// The current connection status.
+ [HttpGet("status")]
+ public ActionResult GetStatus()
+ {
+ return Ok(_obsService.ConnectionStatus);
+ }
+
+ ///
+ /// Tests a connection to the OBS WebSocket server with provided credentials.
+ /// This does not persist the connection - it's only for validation.
+ ///
+ /// The connection test request containing URL and optional password.
+ /// The connection test result.
+ [HttpPost("test-connection")]
+ public async Task TestConnection([FromBody] TestConnectionRequest request)
+ {
+ // Validate the request
+ var validationResponse = TestConnectionRequest.ValidateRequest(request);
+ if (validationResponse.HasErrors)
+ {
+ _logger.LogWarning("Test connection validation failed: {Error}", validationResponse.ErrorMessage);
+ return BadRequest(new
+ {
+ message = validationResponse.ErrorMessage,
+ success = false,
+ hasErrors = true
+ });
+ }
+
+ try
+ {
+ _logger.LogInformation("Testing connection to OBS at {Url}", request.Url);
+
+ // Create a temporary OBS service instance for testing
+ using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
+ var testLogger = loggerFactory.CreateLogger();
+ var clientLogger = loggerFactory.CreateLogger();
+ var testService = new ThriveStreamController.Core.Services.OBSService(testLogger, clientLogger);
+
+ var success = await testService.ConnectAsync(request.Url, request.Password);
+
+ if (success)
+ {
+ // Disconnect immediately after successful test
+ await testService.DisconnectAsync();
+
+ _logger.LogInformation("Test connection successful to {Url}", request.Url);
+ return Ok(new
+ {
+ message = SystemMessages.OBSConnectionSuccess,
+ success = true,
+ hasErrors = false,
+ url = request.Url
+ });
+ }
+
+ _logger.LogWarning("Test connection failed to {Url}", request.Url);
+ return BadRequest(new
+ {
+ message = SystemMessages.OBSConnectionFailed,
+ success = false,
+ hasErrors = true,
+ url = request.Url
+ });
+ }
+ catch (InvalidOperationException ex)
+ {
+ // These are our custom exceptions with user-friendly messages
+ _logger.LogError(ex, "OBS connection error: {Message}", ex.Message);
+ return BadRequest(new
+ {
+ message = ex.Message,
+ success = false,
+ hasErrors = true,
+ url = request.Url
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error testing OBS connection");
+ return StatusCode(500, new
+ {
+ message = SystemMessages.OBSConnectionFailed,
+ error = ex.Message,
+ success = false,
+ hasErrors = true
+ });
+ }
+ }
+
+ ///
+ /// Connects to the OBS WebSocket server using configuration settings.
+ ///
+ /// The connection result.
+ [HttpPost("connect")]
+ public async Task> Connect()
+ {
+ try
+ {
+ var url = _configuration["OBS:WebSocketUrl"] ?? "ws://localhost:4455";
+ var password = _configuration["OBS:Password"];
+
+ var success = await _obsService.ConnectAsync(url, password);
+ if (success)
+ {
+ return Ok(_obsService.ConnectionStatus);
+ }
+
+ return BadRequest(new { message = "Failed to connect to OBS", status = _obsService.ConnectionStatus });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error connecting to OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Disconnects from the OBS WebSocket server.
+ ///
+ /// The disconnection result.
+ [HttpPost("disconnect")]
+ public async Task Disconnect()
+ {
+ try
+ {
+ await _obsService.DisconnectAsync();
+ return Ok(new { message = "Disconnected from OBS" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error disconnecting from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets a list of all available scenes from OBS.
+ ///
+ /// A list of OBS scenes with the current active scene.
+ [HttpGet("scenes")]
+ public async Task> GetScenes()
+ {
+ try
+ {
+ var scenes = await _obsService.GetScenesAsync();
+ var currentScene = scenes.FirstOrDefault(s => s.IsActive)?.Name ?? string.Empty;
+
+ var response = new ScenesResponse
+ {
+ Scenes = scenes,
+ CurrentScene = currentScene
+ };
+
+ return Ok(response);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting scenes from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Switches to the specified scene in OBS.
+ ///
+ /// The scene switch request containing the scene name.
+ /// The scene switch result.
+ [HttpPost("scenes/switch")]
+ public async Task SwitchScene([FromBody] SwitchSceneRequest request)
+ {
+ // Validate the request
+ var validationResponse = SwitchSceneRequest.ValidateRequest(request);
+ if (validationResponse.HasErrors)
+ {
+ _logger.LogWarning("Switch scene validation failed: {Error}", validationResponse.ErrorMessage);
+ return BadRequest(new
+ {
+ message = validationResponse.ErrorMessage,
+ hasErrors = true
+ });
+ }
+
+ try
+ {
+ var success = await _obsService.SwitchSceneAsync(request.SceneName);
+
+ if (success)
+ {
+ return Ok(new
+ {
+ message = string.Format(SystemMessages.OBSSceneSwitchSuccess, request.SceneName),
+ hasErrors = false
+ });
+ }
+
+ return BadRequest(new
+ {
+ message = string.Format(SystemMessages.OBSSceneSwitchFailed, request.SceneName),
+ hasErrors = true
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error switching scene in OBS");
+ return StatusCode(500, new
+ {
+ message = string.Format(SystemMessages.OBSSceneSwitchFailed, request.SceneName),
+ error = ex.Message,
+ hasErrors = true
+ });
+ }
+ }
+
+ ///
+ /// Gets the current streaming status from OBS.
+ ///
+ /// The current streaming status.
+ [HttpGet("streaming/status")]
+ public async Task> GetStreamingStatus()
+ {
+ try
+ {
+ var status = await _obsService.GetStreamingStatusAsync();
+ return Ok(status);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting streaming status from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Starts streaming in OBS.
+ ///
+ /// The start streaming result.
+ [HttpPost("streaming/start")]
+ public async Task StartStreaming()
+ {
+ try
+ {
+ var success = await _obsService.StartStreamingAsync();
+
+ if (success)
+ {
+ return Ok(new { message = "Streaming started" });
+ }
+
+ return BadRequest(new { message = "Failed to start streaming" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting streaming in OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Stops streaming in OBS.
+ ///
+ /// The stop streaming result.
+ [HttpPost("streaming/stop")]
+ public async Task StopStreaming()
+ {
+ try
+ {
+ var success = await _obsService.StopStreamingAsync();
+
+ if (success)
+ {
+ return Ok(new { message = "Streaming stopped" });
+ }
+
+ return BadRequest(new { message = "Failed to stop streaming" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error stopping streaming in OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets the scene items for a specific scene.
+ ///
+ /// The name of the scene.
+ /// A list of scene items.
+ [HttpGet("scenes/{sceneName}/items")]
+ public async Task>> GetSceneItems(string sceneName)
+ {
+ try
+ {
+ var sceneItems = await _obsService.GetSceneItemsAsync(sceneName);
+ return Ok(sceneItems);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting scene items from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets the media input status for a specific input.
+ ///
+ /// The name of the media input.
+ /// The media input status.
+ [HttpGet("media/{inputName}/status")]
+ public async Task> GetMediaInputStatus(string inputName)
+ {
+ try
+ {
+ var status = await _obsService.GetMediaInputStatusAsync(inputName);
+
+ if (status == null)
+ {
+ return NotFound(new { message = $"Media input '{inputName}' not found or not a media source" });
+ }
+
+ return Ok(status);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting media input status from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets the status of the OBS Virtual Camera.
+ ///
+ /// The virtual camera status.
+ [HttpGet("virtualcam/status")]
+ public async Task GetVirtualCamStatus()
+ {
+ try
+ {
+ var isActive = await _obsService.GetVirtualCamStatusAsync();
+ return Ok(new { outputActive = isActive });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting virtual camera status from OBS");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Starts the OBS Virtual Camera.
+ ///
+ /// Success status.
+ [HttpPost("virtualcam/start")]
+ public async Task StartVirtualCam()
+ {
+ try
+ {
+ var success = await _obsService.StartVirtualCamAsync();
+
+ if (success)
+ {
+ return Ok(new { message = "Virtual camera started successfully", success = true });
+ }
+
+ return BadRequest(new { message = "Failed to start virtual camera", success = false });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting virtual camera");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+
+ ///
+ /// Stops the OBS Virtual Camera.
+ ///
+ /// Success status.
+ [HttpPost("virtualcam/stop")]
+ public async Task StopVirtualCam()
+ {
+ try
+ {
+ var success = await _obsService.StopVirtualCamAsync();
+
+ if (success)
+ {
+ return Ok(new { message = "Virtual camera stopped successfully", success = true });
+ }
+
+ return BadRequest(new { message = "Failed to stop virtual camera", success = false });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error stopping virtual camera");
+ return StatusCode(500, new { message = "Internal server error", error = ex.Message });
+ }
+ }
+ }
+
+}
+
diff --git a/API/ThriveStreamController.API/Controllers/YouTubeAuthController.cs b/API/ThriveStreamController.API/Controllers/YouTubeAuthController.cs
new file mode 100644
index 0000000..b9a96cb
--- /dev/null
+++ b/API/ThriveStreamController.API/Controllers/YouTubeAuthController.cs
@@ -0,0 +1,173 @@
+using Microsoft.AspNetCore.Mvc;
+using ThriveStreamController.Core.Interfaces;
+
+namespace ThriveStreamController.API.Controllers
+{
+ ///
+ /// Controller for handling YouTube OAuth authentication flow.
+ ///
+ [ApiController]
+ [Route("api/auth/youtube")]
+ public class YouTubeAuthController : ControllerBase
+ {
+ private readonly IYouTubeAuthService _authService;
+ private readonly ILogger _logger;
+
+ public YouTubeAuthController(
+ IYouTubeAuthService authService,
+ ILogger logger)
+ {
+ _authService = authService;
+ _logger = logger;
+ }
+
+ ///
+ /// Initiates the OAuth authorization flow by redirecting to Google's consent screen.
+ ///
+ /// Redirect to Google OAuth consent screen.
+ [HttpGet("authorize")]
+ public IActionResult Authorize()
+ {
+ try
+ {
+ var state = Guid.NewGuid().ToString();
+ var authUrl = _authService.GetAuthorizationUrl(state);
+
+ _logger.LogDebug("Redirecting to YouTube authorization URL");
+ return Redirect(authUrl);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error initiating YouTube authorization");
+ return BadRequest(new { error = "Failed to initiate authorization", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Handles the OAuth callback from Google after user authorization.
+ ///
+ /// The authorization code from Google.
+ /// The state parameter for CSRF protection.
+ /// Error message if authorization failed.
+ /// Redirect to the UI with success or error message.
+ [HttpGet("callback")]
+ public async Task Callback(
+ [FromQuery] string? code,
+ [FromQuery] string? state,
+ [FromQuery] string? error)
+ {
+ try
+ {
+ // Check if user denied authorization
+ if (!string.IsNullOrEmpty(error))
+ {
+ _logger.LogWarning("YouTube authorization denied: {Error}", error);
+ return Redirect($"http://localhost:5173/settings?youtube_auth=error&message={Uri.EscapeDataString(error)}");
+ }
+
+ // Validate we have an authorization code
+ if (string.IsNullOrEmpty(code))
+ {
+ _logger.LogError("No authorization code received");
+ return Redirect("http://localhost:5173/settings?youtube_auth=error&message=No+authorization+code+received");
+ }
+
+ // Exchange the authorization code for tokens
+ var success = await _authService.ExchangeAuthorizationCodeAsync(code);
+
+ if (success)
+ {
+ _logger.LogInformation("YouTube authorization successful");
+ return Redirect("http://localhost:5173/settings?youtube_auth=success");
+ }
+ else
+ {
+ _logger.LogError("Failed to exchange authorization code");
+ return Redirect("http://localhost:5173/settings?youtube_auth=error&message=Failed+to+exchange+authorization+code");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error handling YouTube OAuth callback");
+ return Redirect($"http://localhost:5173/settings?youtube_auth=error&message={Uri.EscapeDataString(ex.Message)}");
+ }
+ }
+
+ ///
+ /// Checks if YouTube OAuth is configured.
+ ///
+ /// Status of YouTube OAuth configuration.
+ [HttpGet("status")]
+ public async Task GetStatus()
+ {
+ try
+ {
+ var isConfigured = await _authService.IsConfiguredAsync();
+ var connectionTimestamp = isConfigured ? await _authService.GetConnectionTimestampAsync() : null;
+
+ return Ok(new {
+ IsConfigured = isConfigured,
+ Platform = "YouTube",
+ ConnectedAt = connectionTimestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking YouTube OAuth status");
+ return StatusCode(500, new { error = "Failed to check OAuth status", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets information about the authenticated YouTube channel.
+ ///
+ /// YouTube channel information.
+ [HttpGet("channel")]
+ public async Task GetChannelInfo()
+ {
+ try
+ {
+ var isConfigured = await _authService.IsConfiguredAsync();
+ if (!isConfigured)
+ {
+ return BadRequest(new { error = "YouTube is not configured. Please authorize first." });
+ }
+
+ var channelInfo = await _authService.GetChannelInfoAsync();
+
+ if (channelInfo == null)
+ {
+ return NotFound(new { error = "No YouTube channel found for authenticated user" });
+ }
+
+ return Ok(channelInfo);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching YouTube channel information");
+ return StatusCode(500, new { error = "Failed to fetch channel information", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Revokes YouTube OAuth tokens and disconnects the integration.
+ ///
+ /// Success or error response.
+ [HttpPost("revoke")]
+ public async Task Revoke()
+ {
+ try
+ {
+ await _authService.RevokeTokensAsync();
+ _logger.LogInformation("YouTube OAuth tokens revoked");
+ return Ok(new { message = "YouTube integration disconnected successfully" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error revoking YouTube OAuth tokens");
+ return StatusCode(500, new { error = "Failed to revoke tokens", message = ex.Message });
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.API/Controllers/YouTubeLiveController.cs b/API/ThriveStreamController.API/Controllers/YouTubeLiveController.cs
new file mode 100644
index 0000000..db3c7d0
--- /dev/null
+++ b/API/ThriveStreamController.API/Controllers/YouTubeLiveController.cs
@@ -0,0 +1,306 @@
+using Microsoft.AspNetCore.Mvc;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.API.Controllers
+{
+ ///
+ /// Controller for YouTube Live broadcast operations.
+ ///
+ [ApiController]
+ [Route("api/youtube/live")]
+ public class YouTubeLiveController : ControllerBase
+ {
+ private readonly IYouTubeLiveService _liveService;
+ private readonly IYouTubeAuthService _authService;
+ private readonly ILogger _logger;
+
+ public YouTubeLiveController(
+ IYouTubeLiveService liveService,
+ IYouTubeAuthService authService,
+ ILogger logger)
+ {
+ _liveService = liveService;
+ _authService = authService;
+ _logger = logger;
+ }
+
+ ///
+ /// Creates a new YouTube Live broadcast.
+ ///
+ [HttpPost("broadcast")]
+ public async Task> CreateBroadcast([FromBody] CreateBroadcastRequest request)
+ {
+ try
+ {
+ if (!await _authService.IsConfiguredAsync())
+ {
+ return BadRequest(new { error = "YouTube is not configured. Please authorize first." });
+ }
+
+ var broadcast = await _liveService.CreateBroadcastAsync(
+ request.Title,
+ request.Description,
+ request.ScheduledStartTime);
+
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating broadcast");
+ return StatusCode(500, new { error = "Failed to create broadcast", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets or creates the persistent stream configuration.
+ ///
+ [HttpGet("stream")]
+ public async Task> GetPersistentStream()
+ {
+ try
+ {
+ if (!await _authService.IsConfiguredAsync())
+ {
+ return BadRequest(new { error = "YouTube is not configured. Please authorize first." });
+ }
+
+ var stream = await _liveService.GetOrCreatePersistentStreamAsync();
+ return Ok(stream);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting persistent stream");
+ return StatusCode(500, new { error = "Failed to get stream", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Binds a broadcast to the persistent stream.
+ ///
+ [HttpPost("broadcast/{broadcastId}/bind")]
+ public async Task BindBroadcast(string broadcastId)
+ {
+ try
+ {
+ var success = await _liveService.BindBroadcastToStreamAsync(broadcastId);
+ if (success)
+ {
+ return Ok(new { message = "Broadcast bound to stream successfully" });
+ }
+ return BadRequest(new { error = "Failed to bind broadcast to stream" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error binding broadcast {BroadcastId}", broadcastId);
+ return StatusCode(500, new { error = "Failed to bind broadcast", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Transitions a broadcast to testing status.
+ ///
+ [HttpPost("broadcast/{broadcastId}/testing")]
+ public async Task> TransitionToTesting(string broadcastId)
+ {
+ try
+ {
+ var broadcast = await _liveService.TransitionToTestingAsync(broadcastId);
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error transitioning broadcast {BroadcastId} to testing", broadcastId);
+ return StatusCode(500, new { error = "Failed to transition to testing", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Transitions a broadcast to live status.
+ ///
+ [HttpPost("broadcast/{broadcastId}/live")]
+ public async Task> TransitionToLive(string broadcastId)
+ {
+ try
+ {
+ var broadcast = await _liveService.TransitionToLiveAsync(broadcastId);
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error transitioning broadcast {BroadcastId} to live", broadcastId);
+ return StatusCode(500, new { error = "Failed to go live", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Ends a broadcast.
+ ///
+ [HttpPost("broadcast/{broadcastId}/end")]
+ public async Task> EndBroadcast(string broadcastId)
+ {
+ try
+ {
+ var broadcast = await _liveService.EndBroadcastAsync(broadcastId);
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error ending broadcast {BroadcastId}", broadcastId);
+ return StatusCode(500, new { error = "Failed to end broadcast", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets the status of a broadcast.
+ ///
+ [HttpGet("broadcast/{broadcastId}")]
+ public async Task> GetBroadcastStatus(string broadcastId)
+ {
+ try
+ {
+ var broadcast = await _liveService.GetBroadcastStatusAsync(broadcastId);
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting broadcast status {BroadcastId}", broadcastId);
+ return StatusCode(500, new { error = "Failed to get broadcast status", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Updates a broadcast's metadata.
+ ///
+ [HttpPatch("broadcast/{broadcastId}")]
+ public async Task> UpdateBroadcast(
+ string broadcastId,
+ [FromBody] UpdateBroadcastRequest request)
+ {
+ try
+ {
+ var broadcast = await _liveService.UpdateBroadcastAsync(
+ broadcastId,
+ request.Title,
+ request.Description);
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating broadcast {BroadcastId}", broadcastId);
+ return StatusCode(500, new { error = "Failed to update broadcast", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets the currently active broadcast, if any.
+ ///
+ [HttpGet("broadcast/active")]
+ public async Task> GetActiveBroadcast()
+ {
+ try
+ {
+ var broadcast = await _liveService.GetActiveBroadcastAsync();
+ if (broadcast == null)
+ {
+ return NotFound(new { message = "No active broadcast" });
+ }
+ return Ok(broadcast);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting active broadcast");
+ return StatusCode(500, new { error = "Failed to get active broadcast", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Gets broadcast defaults from the most recent broadcast.
+ ///
+ [HttpGet("defaults")]
+ public async Task> GetDefaults()
+ {
+ try
+ {
+ var defaults = await _liveService.GetBroadcastDefaultsAsync();
+ if (defaults == null)
+ {
+ return Ok(new YouTubeBroadcastDefaults());
+ }
+ return Ok(defaults);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting broadcast defaults");
+ return StatusCode(500, new { error = "Failed to get defaults", message = ex.Message });
+ }
+ }
+
+ ///
+ /// Sets the thumbnail for a broadcast.
+ ///
+ [HttpPost("broadcast/{broadcastId}/thumbnail")]
+ public async Task SetThumbnail(string broadcastId, IFormFile file)
+ {
+ try
+ {
+ if (file == null || file.Length == 0)
+ {
+ return BadRequest(new { error = "No file provided" });
+ }
+
+ // Save to temp file
+ var tempPath = Path.GetTempFileName();
+ var extension = Path.GetExtension(file.FileName);
+ var filePath = Path.ChangeExtension(tempPath, extension);
+
+ using (var stream = new FileStream(filePath, FileMode.Create))
+ {
+ await file.CopyToAsync(stream);
+ }
+
+ try
+ {
+ var success = await _liveService.SetThumbnailAsync(broadcastId, filePath);
+ if (success)
+ {
+ return Ok(new { message = "Thumbnail set successfully" });
+ }
+ return BadRequest(new { error = "Failed to set thumbnail" });
+ }
+ finally
+ {
+ // Clean up temp file
+ if (System.IO.File.Exists(filePath))
+ {
+ System.IO.File.Delete(filePath);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error setting thumbnail for broadcast {BroadcastId}", broadcastId);
+ return StatusCode(500, new { error = "Failed to set thumbnail", message = ex.Message });
+ }
+ }
+ }
+
+ ///
+ /// Request model for creating a broadcast.
+ ///
+ public class CreateBroadcastRequest
+ {
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public DateTime? ScheduledStartTime { get; set; }
+ }
+
+ ///
+ /// Request model for updating a broadcast.
+ ///
+ public class UpdateBroadcastRequest
+ {
+ public string? Title { get; set; }
+ public string? Description { get; set; }
+ }
+}
diff --git a/API/ThriveStreamController.API/Hubs/OBSHub.cs b/API/ThriveStreamController.API/Hubs/OBSHub.cs
new file mode 100644
index 0000000..f9bf7de
--- /dev/null
+++ b/API/ThriveStreamController.API/Hubs/OBSHub.cs
@@ -0,0 +1,65 @@
+using Microsoft.AspNetCore.SignalR;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Core.Services;
+
+namespace ThriveStreamController.API.Hubs;
+
+///
+/// SignalR hub for real-time OBS communication
+///
+public class OBSHub : Hub
+{
+ private readonly ILogger _logger;
+ private readonly IOBSService _obsService;
+ private readonly MediaStatusTracker _mediaStatusTracker;
+
+ ///
+ /// Constructor for OBSHub
+ ///
+ public OBSHub(ILogger logger, IOBSService obsService, MediaStatusTracker mediaStatusTracker)
+ {
+ _logger = logger;
+ _obsService = obsService;
+ _mediaStatusTracker = mediaStatusTracker;
+ }
+
+ ///
+ /// Called when a client connects to the hub
+ ///
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("Client connected to OBS Hub: {ConnectionId}", Context.ConnectionId);
+
+ // Send current OBS status to the newly connected client
+ var status = _obsService.GetConnectionStatus();
+ await Clients.Caller.SendAsync("ConnectionStatusChanged", status);
+
+ await base.OnConnectedAsync();
+ }
+
+ ///
+ /// Called when a client disconnects from the hub
+ ///
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogInformation("Client disconnected from OBS Hub: {ConnectionId}", Context.ConnectionId);
+
+ if (exception != null)
+ {
+ _logger.LogError(exception, "Client disconnected with error");
+ }
+
+ await base.OnDisconnectedAsync(exception);
+ }
+
+ ///
+ /// Gets all current media status for all scenes
+ ///
+ public async Task> GetAllMediaStatus()
+ {
+ _logger.LogDebug("Client {ConnectionId} requested all media status", Context.ConnectionId);
+ return await Task.FromResult(_mediaStatusTracker.GetAllMediaStatus());
+ }
+}
+
diff --git a/API/ThriveStreamController.API/Program.cs b/API/ThriveStreamController.API/Program.cs
new file mode 100644
index 0000000..c125651
--- /dev/null
+++ b/API/ThriveStreamController.API/Program.cs
@@ -0,0 +1,122 @@
+using Microsoft.EntityFrameworkCore;
+using Serilog;
+using ThriveStreamController.API.Hubs;
+using ThriveStreamController.API.Services;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Core.Services;
+using ThriveStreamController.Data;
+
+// Configure Serilog
+Log.Logger = new LoggerConfiguration()
+ .WriteTo.Console()
+ .WriteTo.File("logs/thrive-stream-controller-.log", rollingInterval: RollingInterval.Day)
+ .CreateLogger();
+
+try
+{
+ Log.Information("Starting Thrive Stream Controller API");
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Add Serilog
+ builder.Host.UseSerilog();
+
+ // Add services to the container
+ builder.Services.AddControllers()
+ .AddJsonOptions(options =>
+ {
+ // Use PascalCase for JSON serialization (standard for .NET APIs)
+ options.JsonSerializerOptions.PropertyNamingPolicy = null;
+ });
+ builder.Services.AddEndpointsApiExplorer();
+ builder.Services.AddSwaggerGen();
+
+ // Add SignalR with PascalCase JSON serialization
+ builder.Services.AddSignalR()
+ .AddJsonProtocol(options =>
+ {
+ // Use PascalCase for JSON serialization (standard for .NET APIs)
+ options.PayloadSerializerOptions.PropertyNamingPolicy = null;
+ });
+
+ // Add CORS
+ builder.Services.AddCors(options =>
+ {
+ options.AddPolicy("AllowReactApp", policy =>
+ {
+ policy.WithOrigins("http://localhost:5173", "http://localhost:3000")
+ .AllowAnyHeader()
+ .AllowAnyMethod()
+ .AllowCredentials();
+ });
+ });
+
+ // Configure SQLite Database
+ var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
+ ?? "Data Source=thrivestream.db";
+
+ builder.Services.AddDbContext(options =>
+ options.UseSqlite(connectionString));
+
+ // Configure YouTube
+ builder.Services.Configure(
+ builder.Configuration.GetSection("YouTube"));
+
+ // Register application services
+ builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
+
+ // Register media status tracking services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
+
+ var app = builder.Build();
+
+ // Configure the HTTP request pipeline
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI();
+ }
+
+ app.UseSerilogRequestLogging();
+
+ app.UseCors("AllowReactApp");
+
+ // Serve static files (React UI in production)
+ app.UseDefaultFiles();
+ app.UseStaticFiles();
+
+ app.UseAuthorization();
+
+ app.MapControllers();
+ app.MapHub("/hubs/obs");
+
+ // SPA fallback - serve index.html for client-side routing
+ // Must be after MapControllers and MapHub so API routes take precedence
+ app.MapFallbackToFile("index.html");
+
+ // Ensure database is created
+ using (var scope = app.Services.CreateScope())
+ {
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.Database.EnsureCreated();
+ Log.Information("Database initialized");
+ }
+
+ Log.Information("Thrive Stream Controller API started successfully");
+ app.Run();
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "Application terminated unexpectedly");
+}
+finally
+{
+ Log.CloseAndFlush();
+}
diff --git a/API/ThriveStreamController.API/Properties/launchSettings.json b/API/ThriveStreamController.API/Properties/launchSettings.json
new file mode 100644
index 0000000..95f7aa1
--- /dev/null
+++ b/API/ThriveStreamController.API/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5080",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7135;http://localhost:5080",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/API/ThriveStreamController.API/Services/MediaStatusBroadcaster.cs b/API/ThriveStreamController.API/Services/MediaStatusBroadcaster.cs
new file mode 100644
index 0000000..c05515d
--- /dev/null
+++ b/API/ThriveStreamController.API/Services/MediaStatusBroadcaster.cs
@@ -0,0 +1,46 @@
+using Microsoft.AspNetCore.SignalR;
+using ThriveStreamController.API.Hubs;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Core.Services;
+
+namespace ThriveStreamController.API.Services
+{
+ ///
+ /// Service that broadcasts media status updates to SignalR clients.
+ ///
+ public class MediaStatusBroadcaster
+ {
+ private readonly ILogger _logger;
+ private readonly IHubContext _hubContext;
+ private readonly MediaStatusTracker _mediaStatusTracker;
+
+ public MediaStatusBroadcaster(
+ ILogger logger,
+ IHubContext hubContext,
+ MediaStatusTracker mediaStatusTracker)
+ {
+ _logger = logger;
+ _hubContext = hubContext;
+ _mediaStatusTracker = mediaStatusTracker;
+
+ // Subscribe to media status changes
+ _mediaStatusTracker.MediaStatusChanged += OnMediaStatusChanged;
+ }
+
+ private async void OnMediaStatusChanged(object? sender, SceneMediaStatus sceneMediaStatus)
+ {
+ try
+ {
+ _logger.LogDebug("Broadcasting media status update for scene: {SceneName}", sceneMediaStatus.SceneName);
+
+ // Broadcast to all connected clients
+ await _hubContext.Clients.All.SendAsync("MediaStatusChanged", sceneMediaStatus);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting media status change");
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.API/Services/MediaStatusTrackerHostedService.cs b/API/ThriveStreamController.API/Services/MediaStatusTrackerHostedService.cs
new file mode 100644
index 0000000..4525b05
--- /dev/null
+++ b/API/ThriveStreamController.API/Services/MediaStatusTrackerHostedService.cs
@@ -0,0 +1,33 @@
+using ThriveStreamController.Core.Services;
+
+namespace ThriveStreamController.API.Services
+{
+ ///
+ /// Hosted service wrapper for MediaStatusTracker.
+ ///
+ public class MediaStatusTrackerHostedService : IHostedService
+ {
+ private readonly MediaStatusTracker _mediaStatusTracker;
+ private readonly MediaStatusBroadcaster _mediaStatusBroadcaster;
+
+ public MediaStatusTrackerHostedService(
+ MediaStatusTracker mediaStatusTracker,
+ MediaStatusBroadcaster mediaStatusBroadcaster)
+ {
+ _mediaStatusTracker = mediaStatusTracker;
+ _mediaStatusBroadcaster = mediaStatusBroadcaster;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _mediaStatusTracker.Start();
+ return Task.CompletedTask;
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _mediaStatusTracker.StopAsync();
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs b/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs
new file mode 100644
index 0000000..fc10e28
--- /dev/null
+++ b/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs
@@ -0,0 +1,136 @@
+using Microsoft.AspNetCore.SignalR;
+using ThriveStreamController.API.Hubs;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.API.Services;
+
+///
+/// Background service that broadcasts OBS events to SignalR clients
+///
+public class OBSEventBroadcaster : IHostedService
+{
+ private readonly ILogger _logger;
+ private readonly IOBSService _obsService;
+ private readonly IHubContext _hubContext;
+
+ ///
+ /// Constructor for OBSEventBroadcaster
+ ///
+ public OBSEventBroadcaster(
+ ILogger logger,
+ IOBSService obsService,
+ IHubContext hubContext)
+ {
+ _logger = logger;
+ _obsService = obsService;
+ _hubContext = hubContext;
+ }
+
+ ///
+ /// Start the service and subscribe to OBS events
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("OBS Event Broadcaster starting");
+
+ // Subscribe to OBS events
+ _obsService.ConnectionStatusChanged += OnConnectionStatusChanged;
+ _obsService.SceneChanged += OnSceneChanged;
+ _obsService.StreamingStatusChanged += OnStreamingStatusChanged;
+ _obsService.VolumeMetersChanged += OnVolumeMetersChanged;
+
+ _logger.LogInformation("OBS Event Broadcaster started");
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Stop the service and unsubscribe from OBS events
+ ///
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("OBS Event Broadcaster stopping");
+
+ // Unsubscribe from OBS events
+ _obsService.ConnectionStatusChanged -= OnConnectionStatusChanged;
+ _obsService.SceneChanged -= OnSceneChanged;
+ _obsService.StreamingStatusChanged -= OnStreamingStatusChanged;
+ _obsService.VolumeMetersChanged -= OnVolumeMetersChanged;
+
+ _logger.LogInformation("OBS Event Broadcaster stopped");
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Handle OBS connection status changes
+ ///
+ private async void OnConnectionStatusChanged(object? sender, OBSConnectionStatus status)
+ {
+ try
+ {
+ _logger.LogInformation("Broadcasting connection status change: {IsConnected}", status.IsConnected);
+ await _hubContext.Clients.All.SendAsync("ConnectionStatusChanged", status);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting connection status change");
+ }
+ }
+
+ ///
+ /// Handle OBS scene changes
+ ///
+ private async void OnSceneChanged(object? sender, string sceneName)
+ {
+ try
+ {
+ _logger.LogInformation("Broadcasting scene change: {SceneName}", sceneName);
+ await _hubContext.Clients.All.SendAsync("SceneChanged", sceneName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting scene change");
+ }
+ }
+
+ ///
+ /// Handle OBS streaming status changes
+ ///
+ private async void OnStreamingStatusChanged(object? sender, StreamingStatus status)
+ {
+ try
+ {
+ _logger.LogInformation("Broadcasting streaming status change: {IsStreaming}", status.IsStreaming);
+ await _hubContext.Clients.All.SendAsync("StreamingStatusChanged", status.IsStreaming);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting streaming status change");
+ }
+ }
+
+ ///
+ /// Handle OBS volume meters updates
+ ///
+ private async void OnVolumeMetersChanged(object? sender, InputVolumeMetersData volumeMeters)
+ {
+ try
+ {
+ // Log every 100th update to see what we're getting (events fire every 50ms)
+ if (volumeMeters.Inputs.Count > 0 && _eventCounter % 100 == 0)
+ {
+ _logger.LogDebug("Broadcasting volume meters: {InputCount} inputs - {Names}",
+ volumeMeters.Inputs.Count,
+ string.Join(", ", volumeMeters.Inputs.Select(i => i.InputName)));
+ }
+ await _hubContext.Clients.All.SendAsync("VolumeMetersChanged", volumeMeters);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting volume meters change");
+ }
+ }
+
+ private int _eventCounter = 0;
+}
+
diff --git a/API/ThriveStreamController.API/ThriveStreamController.API.csproj b/API/ThriveStreamController.API/ThriveStreamController.API.csproj
new file mode 100644
index 0000000..9bd8f9d
--- /dev/null
+++ b/API/ThriveStreamController.API/ThriveStreamController.API.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/API/ThriveStreamController.API/appsettings.Development.json b/API/ThriveStreamController.API/appsettings.Development.json
new file mode 100644
index 0000000..886a9d5
--- /dev/null
+++ b/API/ThriveStreamController.API/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "OBS": {
+ "Password": ""
+ }
+}
diff --git a/API/ThriveStreamController.API/appsettings.json b/API/ThriveStreamController.API/appsettings.json
new file mode 100644
index 0000000..d7a10d2
--- /dev/null
+++ b/API/ThriveStreamController.API/appsettings.json
@@ -0,0 +1,43 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "DefaultConnection": "Data Source=thrivestream.db"
+ },
+ "OBS": {
+ "WebSocketUrl": "ws://localhost:4455",
+ "Password": "",
+ "AutoReconnect": true,
+ "ReconnectDelaySeconds": 5
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "System": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console"
+ },
+ {
+ "Name": "File",
+ "Args": {
+ "path": "logs/thrive-stream-controller-.log",
+ "rollingInterval": "Day",
+ "retainedFileCountLimit": 7,
+ "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
+ }
+}
diff --git a/API/ThriveStreamController.Core/Interfaces/ICredentialEncryptionService.cs b/API/ThriveStreamController.Core/Interfaces/ICredentialEncryptionService.cs
new file mode 100644
index 0000000..6ead090
--- /dev/null
+++ b/API/ThriveStreamController.Core/Interfaces/ICredentialEncryptionService.cs
@@ -0,0 +1,23 @@
+namespace ThriveStreamController.Core.Interfaces
+{
+ ///
+ /// Service for encrypting and decrypting sensitive credentials (OAuth tokens, access tokens, etc.).
+ ///
+ public interface ICredentialEncryptionService
+ {
+ ///
+ /// Encrypts a plaintext credential value.
+ ///
+ /// The plaintext value to encrypt.
+ /// The encrypted value as a base64-encoded string.
+ Task EncryptAsync(string plainText);
+
+ ///
+ /// Decrypts an encrypted credential value.
+ ///
+ /// The encrypted value (base64-encoded string).
+ /// The decrypted plaintext value.
+ Task DecryptAsync(string encryptedText);
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Interfaces/IOBSService.cs b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs
new file mode 100644
index 0000000..c4ba3b2
--- /dev/null
+++ b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs
@@ -0,0 +1,120 @@
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.Core.Interfaces
+{
+ ///
+ /// Interface for OBS WebSocket service operations.
+ /// Provides methods to connect, disconnect, and interact with OBS Studio.
+ ///
+ public interface IOBSService
+ {
+ ///
+ /// Gets the current connection status.
+ ///
+ OBSConnectionStatus ConnectionStatus { get; }
+
+ ///
+ /// Event raised when the connection status changes.
+ ///
+ event EventHandler? ConnectionStatusChanged;
+
+ ///
+ /// Event raised when the active scene changes in OBS.
+ ///
+ event EventHandler? SceneChanged;
+
+ ///
+ /// Event raised when the streaming status changes in OBS.
+ ///
+ event EventHandler? StreamingStatusChanged;
+
+ ///
+ /// Event raised when audio volume meters are updated (every 50ms).
+ ///
+ event EventHandler? VolumeMetersChanged;
+
+ ///
+ /// Connects to the OBS WebSocket server.
+ ///
+ /// The WebSocket server URL (e.g., "ws://localhost:4455").
+ /// The WebSocket server password (optional).
+ /// A task that represents the asynchronous connect operation. Returns true if connection was successful.
+ Task ConnectAsync(string url, string? password = null);
+
+ ///
+ /// Disconnects from the OBS WebSocket server.
+ ///
+ /// A task that represents the asynchronous disconnect operation.
+ Task DisconnectAsync();
+
+ ///
+ /// Gets a list of all available scenes from OBS.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains a list of OBS scenes.
+ Task> GetScenesAsync();
+
+ ///
+ /// Switches to the specified scene in OBS.
+ ///
+ /// The name of the scene to switch to.
+ /// A task that represents the asynchronous operation. Returns true if the scene was switched successfully.
+ Task SwitchSceneAsync(string sceneName);
+
+ ///
+ /// Gets the current streaming status from OBS.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains the current streaming status.
+ Task GetStreamingStatusAsync();
+
+ ///
+ /// Starts streaming in OBS.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if streaming started successfully.
+ Task StartStreamingAsync();
+
+ ///
+ /// Stops streaming in OBS.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if streaming stopped successfully.
+ Task StopStreamingAsync();
+
+ ///
+ /// Gets the current connection status.
+ ///
+ /// The current OBS connection status.
+ OBSConnectionStatus GetConnectionStatus();
+
+ ///
+ /// Gets the list of scene items for a specific scene.
+ ///
+ /// The name of the scene to get items for.
+ /// A task that represents the asynchronous operation. The task result contains a list of scene items.
+ Task> GetSceneItemsAsync(string sceneName);
+
+ ///
+ /// Gets the media input status for a specific input.
+ ///
+ /// The name of the media input.
+ /// A task that represents the asynchronous operation. The task result contains the media input status.
+ Task GetMediaInputStatusAsync(string inputName);
+
+ ///
+ /// Gets the status of the OBS Virtual Camera.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if the virtual camera is active.
+ Task GetVirtualCamStatusAsync();
+
+ ///
+ /// Starts the OBS Virtual Camera.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if successful.
+ Task StartVirtualCamAsync();
+
+ ///
+ /// Stops the OBS Virtual Camera.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if successful.
+ Task StopVirtualCamAsync();
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Interfaces/IYouTubeAuthService.cs b/API/ThriveStreamController.Core/Interfaces/IYouTubeAuthService.cs
new file mode 100644
index 0000000..49c645f
--- /dev/null
+++ b/API/ThriveStreamController.Core/Interfaces/IYouTubeAuthService.cs
@@ -0,0 +1,59 @@
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.Core.Interfaces
+{
+ ///
+ /// Service for handling YouTube OAuth 2.0 authentication flow.
+ ///
+ public interface IYouTubeAuthService
+ {
+ ///
+ /// Generates the OAuth authorization URL for the user to visit.
+ ///
+ /// Optional state parameter for CSRF protection.
+ /// The authorization URL.
+ string GetAuthorizationUrl(string? state = null);
+
+ ///
+ /// Exchanges an authorization code for access and refresh tokens.
+ ///
+ /// The authorization code from the OAuth callback.
+ /// Cancellation token.
+ /// True if successful, false otherwise.
+ Task ExchangeAuthorizationCodeAsync(string authorizationCode, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a valid access token, refreshing if necessary.
+ ///
+ /// Cancellation token.
+ /// A valid access token.
+ Task GetAccessTokenAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Checks if OAuth is configured (has refresh token).
+ ///
+ /// True if OAuth is configured, false otherwise.
+ Task IsConfiguredAsync();
+
+ ///
+ /// Revokes the current OAuth tokens and clears stored credentials.
+ ///
+ /// Cancellation token.
+ Task RevokeTokensAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets information about the authenticated YouTube channel.
+ ///
+ /// Cancellation token.
+ /// YouTube channel information.
+ Task GetChannelInfoAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the date and time when YouTube was first connected.
+ ///
+ /// Cancellation token.
+ /// The connection timestamp, or null if not connected.
+ Task GetConnectionTimestampAsync(CancellationToken cancellationToken = default);
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Interfaces/IYouTubeLiveService.cs b/API/ThriveStreamController.Core/Interfaces/IYouTubeLiveService.cs
new file mode 100644
index 0000000..bdf9978
--- /dev/null
+++ b/API/ThriveStreamController.Core/Interfaces/IYouTubeLiveService.cs
@@ -0,0 +1,115 @@
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.Core.Interfaces
+{
+ ///
+ /// Service for managing YouTube Live broadcasts.
+ /// Handles the full broadcast lifecycle: create, bind stream, go live, end.
+ ///
+ public interface IYouTubeLiveService
+ {
+ ///
+ /// Creates a new YouTube Live broadcast with the specified settings.
+ /// Uses a persistent stream so OBS/Castr configuration never changes.
+ ///
+ /// The broadcast title.
+ /// The broadcast description.
+ /// Optional scheduled start time. Defaults to now.
+ /// Cancellation token.
+ /// Information about the created broadcast.
+ Task CreateBroadcastAsync(
+ string title,
+ string? description = null,
+ DateTime? scheduledStartTime = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets or creates a persistent stream that can be reused across broadcasts.
+ /// This ensures the stream key and RTMP URL never change.
+ ///
+ /// Cancellation token.
+ /// The persistent stream information including stream key and RTMP URL.
+ Task GetOrCreatePersistentStreamAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Binds a broadcast to the persistent stream.
+ /// Must be called before transitioning to live.
+ ///
+ /// The broadcast ID to bind.
+ /// Cancellation token.
+ /// True if binding was successful.
+ Task BindBroadcastToStreamAsync(string broadcastId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Transitions the broadcast to live status.
+ /// Should only be called after OBS has started streaming.
+ ///
+ /// The broadcast ID to transition.
+ /// Cancellation token.
+ /// Updated broadcast information.
+ Task TransitionToLiveAsync(string broadcastId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Transitions the broadcast to testing status.
+ /// Used when the stream is receiving video but not yet public.
+ ///
+ /// The broadcast ID to transition.
+ /// Cancellation token.
+ /// Updated broadcast information.
+ Task TransitionToTestingAsync(string broadcastId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Ends the broadcast and marks it as complete.
+ ///
+ /// The broadcast ID to end.
+ /// Cancellation token.
+ /// Updated broadcast information.
+ Task EndBroadcastAsync(string broadcastId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the current status of a broadcast.
+ ///
+ /// The broadcast ID to check.
+ /// Cancellation token.
+ /// Current broadcast information.
+ Task GetBroadcastStatusAsync(string broadcastId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Sets the thumbnail for a broadcast.
+ ///
+ /// The broadcast ID.
+ /// Path to the thumbnail image file.
+ /// Cancellation token.
+ /// True if successful.
+ Task SetThumbnailAsync(string broadcastId, string thumbnailPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates broadcast metadata (title and/or description).
+ ///
+ /// The broadcast ID to update.
+ /// New title (optional).
+ /// New description (optional).
+ /// Cancellation token.
+ /// Updated broadcast information.
+ Task UpdateBroadcastAsync(
+ string broadcastId,
+ string? title = null,
+ string? description = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the currently active broadcast, if any.
+ ///
+ /// Cancellation token.
+ /// Active broadcast info or null.
+ Task GetActiveBroadcastAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets broadcast defaults/template from the most recent broadcast.
+ ///
+ /// Cancellation token.
+ /// Default settings from the most recent broadcast.
+ Task GetBroadcastDefaultsAsync(CancellationToken cancellationToken = default);
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/InputVolumeMeter.cs b/API/ThriveStreamController.Core/Models/InputVolumeMeter.cs
new file mode 100644
index 0000000..b5157a2
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/InputVolumeMeter.cs
@@ -0,0 +1,41 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents volume meter data for a single audio input.
+ ///
+ public class InputVolumeMeter
+ {
+ ///
+ /// Name of the input.
+ ///
+ public string InputName { get; set; } = string.Empty;
+
+ ///
+ /// UUID of the input.
+ ///
+ public string InputUuid { get; set; } = string.Empty;
+
+ ///
+ /// Array of volume levels for each channel.
+ /// Each value represents the volume level in dB (typically ranging from -60 to 0).
+ ///
+ public List InputLevelsMul { get; set; } = new List();
+
+ ///
+ /// Whether the input is muted.
+ ///
+ public bool InputMuted { get; set; } = false;
+ }
+
+ ///
+ /// Represents the volume meters event data containing all active inputs.
+ ///
+ public class InputVolumeMetersData
+ {
+ ///
+ /// Array of active inputs with their associated volume levels.
+ ///
+ public List Inputs { get; set; } = new List();
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/MediaInputStatus.cs b/API/ThriveStreamController.Core/Models/MediaInputStatus.cs
new file mode 100644
index 0000000..53e8de9
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/MediaInputStatus.cs
@@ -0,0 +1,42 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents the status of a media input in OBS.
+ ///
+ public class MediaInputStatus
+ {
+ ///
+ /// Gets or sets the state of the media input.
+ ///
+ public string MediaState { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the total duration of the media in milliseconds.
+ /// Null if not playing.
+ ///
+ public long? MediaDuration { get; set; }
+
+ ///
+ /// Gets or sets the current cursor position in milliseconds.
+ /// Null if not playing.
+ ///
+ public long? MediaCursor { get; set; }
+
+ ///
+ /// Gets the remaining time in milliseconds.
+ /// Null if not playing or duration is unknown.
+ ///
+ public long? RemainingTime
+ {
+ get
+ {
+ if (MediaDuration.HasValue && MediaCursor.HasValue)
+ {
+ return MediaDuration.Value - MediaCursor.Value;
+ }
+ return null;
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/OBSConnectionStatus.cs b/API/ThriveStreamController.Core/Models/OBSConnectionStatus.cs
new file mode 100644
index 0000000..ad10476
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/OBSConnectionStatus.cs
@@ -0,0 +1,34 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents the connection status of the OBS WebSocket connection.
+ ///
+ public class OBSConnectionStatus
+ {
+ ///
+ /// Gets or sets a value indicating whether the connection to OBS is established.
+ ///
+ public bool IsConnected { get; set; }
+
+ ///
+ /// Gets or sets the OBS WebSocket server address.
+ ///
+ public string ServerUrl { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the last error message if connection failed.
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// Gets or sets the timestamp of the last connection attempt.
+ ///
+ public DateTime? LastConnectionAttempt { get; set; }
+
+ ///
+ /// Gets or sets the timestamp when the connection was established.
+ ///
+ public DateTime? ConnectedAt { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/OBSScene.cs b/API/ThriveStreamController.Core/Models/OBSScene.cs
new file mode 100644
index 0000000..a5e1286
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/OBSScene.cs
@@ -0,0 +1,24 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents an OBS scene with its name and active status.
+ ///
+ public class OBSScene
+ {
+ ///
+ /// Gets or sets the name of the scene.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether this scene is currently active.
+ ///
+ public bool IsActive { get; set; }
+
+ ///
+ /// Gets or sets the index of the scene in the OBS scene list.
+ ///
+ public int Index { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/Requests/SwitchSceneRequest.cs b/API/ThriveStreamController.Core/Models/Requests/SwitchSceneRequest.cs
new file mode 100644
index 0000000..883de98
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/Requests/SwitchSceneRequest.cs
@@ -0,0 +1,36 @@
+using ThriveStreamController.Core.System;
+
+namespace ThriveStreamController.Core.Models.Requests
+{
+ ///
+ /// Request model for switching scenes.
+ ///
+ public class SwitchSceneRequest
+ {
+ ///
+ /// Gets or sets the name of the scene to switch to.
+ ///
+ public string SceneName { get; set; } = string.Empty;
+
+ ///
+ /// Validates the switch scene request.
+ ///
+ /// The request to validate.
+ /// A validation response indicating success or failure.
+ public static ValidationResponse ValidateRequest(SwitchSceneRequest? request)
+ {
+ if (request == null)
+ {
+ return new ValidationResponse(true, SystemMessages.EmptyRequest);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.SceneName))
+ {
+ return new ValidationResponse(true, SystemMessages.OBSSceneNameRequired);
+ }
+
+ return new ValidationResponse(SystemMessages.Success);
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/Requests/TestConnectionRequest.cs b/API/ThriveStreamController.Core/Models/Requests/TestConnectionRequest.cs
new file mode 100644
index 0000000..9819f35
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/Requests/TestConnectionRequest.cs
@@ -0,0 +1,54 @@
+using ThriveStreamController.Core.System;
+
+namespace ThriveStreamController.Core.Models.Requests
+{
+ ///
+ /// Request model for testing OBS connection.
+ ///
+ public class TestConnectionRequest
+ {
+ ///
+ /// Gets or sets the WebSocket URL to test (e.g., "ws://localhost:4455").
+ ///
+ public string Url { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the optional password for the WebSocket connection.
+ ///
+ public string? Password { get; set; }
+
+ ///
+ /// Validates the test connection request.
+ ///
+ /// The request to validate.
+ /// A validation response indicating success or failure.
+ public static ValidationResponse ValidateRequest(TestConnectionRequest? request)
+ {
+ if (request == null)
+ {
+ return new ValidationResponse(true, SystemMessages.EmptyRequest);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Url))
+ {
+ return new ValidationResponse(true, SystemMessages.OBSUrlRequired);
+ }
+
+ // Validate URL format
+ if (!request.Url.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) &&
+ !request.Url.StartsWith("wss://", StringComparison.OrdinalIgnoreCase))
+ {
+ return new ValidationResponse(true, SystemMessages.OBSInvalidUrl);
+ }
+
+ // Validate URL is a valid URI
+ if (!Uri.TryCreate(request.Url, UriKind.Absolute, out _))
+ {
+ return new ValidationResponse(true, string.Format(SystemMessages.InvalidProperty, "Url", "Must be a valid WebSocket URL"));
+ }
+
+ return new ValidationResponse(SystemMessages.Success);
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/SceneItem.cs b/API/ThriveStreamController.Core/Models/SceneItem.cs
new file mode 100644
index 0000000..8fd3121
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/SceneItem.cs
@@ -0,0 +1,39 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents an item (source) within an OBS scene.
+ ///
+ public class SceneItem
+ {
+ ///
+ /// Gets or sets the numeric ID of the scene item.
+ ///
+ public int SceneItemId { get; set; }
+
+ ///
+ /// Gets or sets the index position of the scene item.
+ ///
+ public int SceneItemIndex { get; set; }
+
+ ///
+ /// Gets or sets the name of the source.
+ ///
+ public string SourceName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the UUID of the source.
+ ///
+ public string SourceUuid { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the type of the source.
+ ///
+ public string SourceType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets whether the scene item is enabled.
+ ///
+ public bool SceneItemEnabled { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/SceneMediaStatus.cs b/API/ThriveStreamController.Core/Models/SceneMediaStatus.cs
new file mode 100644
index 0000000..860f2ea
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/SceneMediaStatus.cs
@@ -0,0 +1,24 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents media status for a specific scene.
+ ///
+ public class SceneMediaStatus
+ {
+ ///
+ /// Gets or sets the scene name.
+ ///
+ public string SceneName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the media input name.
+ ///
+ public string? MediaInputName { get; set; }
+
+ ///
+ /// Gets or sets the media input status.
+ ///
+ public MediaInputStatus? Status { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/ScenesResponse.cs b/API/ThriveStreamController.Core/Models/ScenesResponse.cs
new file mode 100644
index 0000000..45c3598
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/ScenesResponse.cs
@@ -0,0 +1,19 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Response containing the list of scenes and the current active scene.
+ ///
+ public class ScenesResponse
+ {
+ ///
+ /// Gets or sets the name of the currently active scene.
+ ///
+ public string CurrentScene { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the list of all available scenes.
+ ///
+ public List Scenes { get; set; } = new();
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/StreamingStatus.cs b/API/ThriveStreamController.Core/Models/StreamingStatus.cs
new file mode 100644
index 0000000..4b68ee6
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/StreamingStatus.cs
@@ -0,0 +1,34 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents the current streaming status from OBS.
+ ///
+ public class StreamingStatus
+ {
+ ///
+ /// Gets or sets a value indicating whether OBS is currently streaming.
+ ///
+ public bool IsStreaming { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether OBS is currently recording.
+ ///
+ public bool IsRecording { get; set; }
+
+ ///
+ /// Gets or sets the name of the currently active scene.
+ ///
+ public string? CurrentScene { get; set; }
+
+ ///
+ /// Gets or sets the duration of the current stream in seconds.
+ ///
+ public int StreamDurationSeconds { get; set; }
+
+ ///
+ /// Gets or sets the timestamp when the stream started.
+ ///
+ public DateTime? StreamStartTime { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/YouTubeBroadcastInfo.cs b/API/ThriveStreamController.Core/Models/YouTubeBroadcastInfo.cs
new file mode 100644
index 0000000..67d0f1d
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/YouTubeBroadcastInfo.cs
@@ -0,0 +1,159 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Represents information about a YouTube Live broadcast.
+ ///
+ public class YouTubeBroadcastInfo
+ {
+ ///
+ /// Gets or sets the broadcast ID.
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the broadcast title.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the broadcast description.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the scheduled start time.
+ ///
+ public DateTime? ScheduledStartTime { get; set; }
+
+ ///
+ /// Gets or sets the actual start time when the broadcast went live.
+ ///
+ public DateTime? ActualStartTime { get; set; }
+
+ ///
+ /// Gets or sets the actual end time when the broadcast ended.
+ ///
+ public DateTime? ActualEndTime { get; set; }
+
+ ///
+ /// Gets or sets the broadcast lifecycle status.
+ /// Values: created, ready, testing, live, complete, revoked
+ ///
+ public string LifecycleStatus { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the privacy status.
+ /// Values: public, private, unlisted
+ ///
+ public string PrivacyStatus { get; set; } = "public";
+
+ ///
+ /// Gets or sets the stream ID this broadcast is bound to.
+ ///
+ public string? BoundStreamId { get; set; }
+
+ ///
+ /// Gets or sets the URL to watch the broadcast.
+ ///
+ public string? WatchUrl { get; set; }
+
+ ///
+ /// Gets or sets the embed HTML for the broadcast.
+ ///
+ public string? EmbedHtml { get; set; }
+
+ ///
+ /// Gets or sets the thumbnail URL.
+ ///
+ public string? ThumbnailUrl { get; set; }
+
+ ///
+ /// Gets or sets whether this broadcast is currently live.
+ ///
+ public bool IsLive => LifecycleStatus == "live";
+
+ ///
+ /// Gets or sets whether this broadcast has ended.
+ ///
+ public bool IsComplete => LifecycleStatus == "complete";
+
+ ///
+ /// Gets or sets any error message associated with the broadcast.
+ ///
+ public string? ErrorMessage { get; set; }
+ }
+
+ ///
+ /// Represents information about a YouTube Live stream (ingestion point).
+ ///
+ public class YouTubeStreamInfo
+ {
+ ///
+ /// Gets or sets the stream ID.
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the stream title/name.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the stream key for RTMP ingestion.
+ ///
+ public string StreamKey { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the RTMP ingestion URL.
+ ///
+ public string RtmpUrl { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the full ingestion address (RTMP URL + stream key).
+ ///
+ public string IngestionAddress => $"{RtmpUrl}/{StreamKey}";
+
+ ///
+ /// Gets or sets the stream health status.
+ /// Values: good, ok, bad, noData
+ ///
+ public string? HealthStatus { get; set; }
+
+ ///
+ /// Gets or sets whether the stream is receiving video.
+ ///
+ public bool IsActive { get; set; }
+ }
+
+ ///
+ /// Represents default settings from a previous broadcast (template).
+ ///
+ public class YouTubeBroadcastDefaults
+ {
+ ///
+ /// Gets or sets the default title template.
+ ///
+ public string? TitleTemplate { get; set; }
+
+ ///
+ /// Gets or sets the default description.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the default privacy status.
+ ///
+ public string PrivacyStatus { get; set; } = "public";
+
+ ///
+ /// Gets or sets the default thumbnail path.
+ ///
+ public string? ThumbnailPath { get; set; }
+
+ ///
+ /// Gets or sets the ID of the broadcast these defaults came from.
+ ///
+ public string? SourceBroadcastId { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/YouTubeChannelInfo.cs b/API/ThriveStreamController.Core/Models/YouTubeChannelInfo.cs
new file mode 100644
index 0000000..fd69999
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/YouTubeChannelInfo.cs
@@ -0,0 +1,59 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Information about a YouTube channel.
+ ///
+ public class YouTubeChannelInfo
+ {
+ ///
+ /// Gets or sets the channel ID.
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the channel title/name.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the channel description.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Gets or sets the custom URL for the channel.
+ ///
+ public string? CustomUrl { get; set; }
+
+ ///
+ /// Gets or sets the channel thumbnail URL.
+ ///
+ public string? ThumbnailUrl { get; set; }
+
+ ///
+ /// Gets or sets the subscriber count.
+ ///
+ public long? SubscriberCount { get; set; }
+
+ ///
+ /// Gets or sets the video count.
+ ///
+ public long? VideoCount { get; set; }
+
+ ///
+ /// Gets or sets the view count.
+ ///
+ public long? ViewCount { get; set; }
+
+ ///
+ /// Gets or sets whether subscriber count is hidden.
+ ///
+ public bool HiddenSubscriberCount { get; set; }
+
+ ///
+ /// Gets or sets the date the channel was published.
+ ///
+ public DateTime? PublishedAt { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Models/YouTubeConfiguration.cs b/API/ThriveStreamController.Core/Models/YouTubeConfiguration.cs
new file mode 100644
index 0000000..5b3603c
--- /dev/null
+++ b/API/ThriveStreamController.Core/Models/YouTubeConfiguration.cs
@@ -0,0 +1,76 @@
+namespace ThriveStreamController.Core.Models
+{
+ ///
+ /// Configuration settings for YouTube Live Streaming API integration.
+ ///
+ public class YouTubeConfiguration
+ {
+ ///
+ /// Gets or sets the OAuth 2.0 Client ID from Google Cloud Console.
+ ///
+ public string ClientId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the OAuth 2.0 Client Secret from Google Cloud Console.
+ ///
+ public string ClientSecret { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the OAuth 2.0 Refresh Token for accessing the YouTube API.
+ /// This is obtained during the initial OAuth authorization flow.
+ ///
+ public string? RefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the persistent broadcast ID that will be reused for all streams.
+ /// If not set, a new broadcast will be created on first use.
+ ///
+ public string? PersistentBroadcastId { get; set; }
+
+ ///
+ /// Gets or sets the persistent stream ID that will be reused for all streams.
+ /// If not set, a new stream will be created on first use.
+ ///
+ public string? PersistentStreamId { get; set; }
+
+ ///
+ /// Gets or sets the YouTube Channel ID.
+ ///
+ public string? ChannelId { get; set; }
+
+ ///
+ /// Gets or sets the redirect URI for OAuth callback.
+ /// Default: http://localhost:5080/api/auth/youtube/callback
+ ///
+ public string RedirectUri { get; set; } = "http://localhost:5080/api/auth/youtube/callback";
+
+ ///
+ /// Gets or sets the OAuth scopes required for YouTube Live Streaming.
+ ///
+ public string[] Scopes { get; set; } = new[]
+ {
+ "https://www.googleapis.com/auth/youtube",
+ "https://www.googleapis.com/auth/youtube.force-ssl"
+ };
+
+ ///
+ /// Validates that the configuration has the minimum required settings.
+ ///
+ /// True if configuration is valid, false otherwise.
+ public bool IsValid()
+ {
+ return !string.IsNullOrWhiteSpace(ClientId) &&
+ !string.IsNullOrWhiteSpace(ClientSecret);
+ }
+
+ ///
+ /// Checks if OAuth is configured (has refresh token).
+ ///
+ /// True if OAuth is configured, false otherwise.
+ public bool IsOAuthConfigured()
+ {
+ return IsValid() && !string.IsNullOrWhiteSpace(RefreshToken);
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Services/CredentialEncryptionService.cs b/API/ThriveStreamController.Core/Services/CredentialEncryptionService.cs
new file mode 100644
index 0000000..e601d7d
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/CredentialEncryptionService.cs
@@ -0,0 +1,89 @@
+using Microsoft.AspNetCore.DataProtection;
+using System.Security.Cryptography;
+using System.Text;
+using ThriveStreamController.Core.Interfaces;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Implementation of credential encryption service using ASP.NET Core Data Protection API.
+ ///
+ public class CredentialEncryptionService : ICredentialEncryptionService
+ {
+ private readonly IDataProtector _protector;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The data protection provider.
+ public CredentialEncryptionService(IDataProtectionProvider dataProtectionProvider)
+ {
+ // Create a protector with a specific purpose string
+ // This ensures that data encrypted for this purpose cannot be decrypted by other purposes
+ _protector = dataProtectionProvider.CreateProtector("ThriveStreamController.Credentials.v1");
+ }
+
+ ///
+ public Task EncryptAsync(string plainText)
+ {
+ if (string.IsNullOrEmpty(plainText))
+ {
+ throw new ArgumentException("Plain text cannot be null or empty.", nameof(plainText));
+ }
+
+ try
+ {
+ // Convert plaintext to bytes
+ var plainBytes = Encoding.UTF8.GetBytes(plainText);
+
+ // Protect (encrypt) the data
+ var protectedBytes = _protector.Protect(plainBytes);
+
+ // Convert to base64 for storage
+ var encryptedText = Convert.ToBase64String(protectedBytes);
+
+ return Task.FromResult(encryptedText);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to encrypt credential.", ex);
+ }
+ }
+
+ ///
+ public Task DecryptAsync(string encryptedText)
+ {
+ if (string.IsNullOrEmpty(encryptedText))
+ {
+ throw new ArgumentException("Encrypted text cannot be null or empty.", nameof(encryptedText));
+ }
+
+ try
+ {
+ // Convert from base64
+ var protectedBytes = Convert.FromBase64String(encryptedText);
+
+ // Unprotect (decrypt) the data
+ var plainBytes = _protector.Unprotect(protectedBytes);
+
+ // Convert bytes back to string
+ var plainText = Encoding.UTF8.GetString(plainBytes);
+
+ return Task.FromResult(plainText);
+ }
+ catch (FormatException ex)
+ {
+ throw new InvalidOperationException("Invalid encrypted text format.", ex);
+ }
+ catch (CryptographicException ex)
+ {
+ throw new InvalidOperationException("Failed to decrypt credential. The data may be corrupted or encrypted with a different key.", ex);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to decrypt credential.", ex);
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs b/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs
new file mode 100644
index 0000000..cfce016
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs
@@ -0,0 +1,202 @@
+using Microsoft.Extensions.Logging;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Service that tracks media status for all scenes and broadcasts updates.
+ ///
+ public class MediaStatusTracker
+ {
+ private readonly ILogger _logger;
+ private readonly IOBSService _obsService;
+ private readonly Dictionary _sceneMediaCache = new();
+ private readonly object _cacheLock = new object();
+ private List _scenes = new();
+ private Task? _trackingTask;
+ private CancellationTokenSource? _cancellationTokenSource;
+
+ public event EventHandler? MediaStatusChanged;
+
+ public MediaStatusTracker(ILogger logger, IOBSService obsService)
+ {
+ _logger = logger;
+ _obsService = obsService;
+ }
+
+ public void Start()
+ {
+ if (_trackingTask != null)
+ {
+ return; // Already started
+ }
+
+ _cancellationTokenSource = new CancellationTokenSource();
+ _trackingTask = Task.Run(() => ExecuteAsync(_cancellationTokenSource.Token));
+ }
+
+ public async Task StopAsync()
+ {
+ if (_cancellationTokenSource != null)
+ {
+ _cancellationTokenSource.Cancel();
+ }
+
+ if (_trackingTask != null)
+ {
+ await _trackingTask;
+ }
+ }
+
+ private async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Media Status Tracker started");
+
+ // Wait for OBS to be connected
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ if (_obsService.ConnectionStatus.IsConnected)
+ {
+ break;
+ }
+ await Task.Delay(1000, stoppingToken);
+ }
+
+ // Main tracking loop
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (_obsService.ConnectionStatus.IsConnected)
+ {
+ await UpdateAllSceneMediaStatusAsync();
+ }
+
+ // Poll every second for cursor updates
+ await Task.Delay(1000, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in media status tracking loop");
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+
+ _logger.LogDebug("Media Status Tracker stopped");
+ }
+
+ private async Task UpdateAllSceneMediaStatusAsync()
+ {
+ try
+ {
+ // Get all scenes
+ var scenes = await _obsService.GetScenesAsync();
+ _scenes = scenes;
+
+ // Check each scene for media sources
+ foreach (var scene in scenes)
+ {
+ await UpdateSceneMediaStatusAsync(scene.Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating all scene media status");
+ }
+ }
+
+ private async Task UpdateSceneMediaStatusAsync(string sceneName)
+ {
+ try
+ {
+ // Get scene items
+ var sceneItems = await _obsService.GetSceneItemsAsync(sceneName);
+
+ // Find first media source
+ SceneItem? mediaSource = null;
+ foreach (var item in sceneItems)
+ {
+ if (item.SourceType == "OBS_SOURCE_TYPE_INPUT" && item.SceneItemEnabled)
+ {
+ // Try to get media status to see if it's a media source
+ try
+ {
+ var status = await _obsService.GetMediaInputStatusAsync(item.SourceName);
+ if (status != null)
+ {
+ mediaSource = item;
+
+ // Create or update scene media status
+ var sceneMediaStatus = new SceneMediaStatus
+ {
+ SceneName = sceneName,
+ MediaInputName = item.SourceName,
+ Status = status
+ };
+
+ // Check if status has changed
+ bool hasChanged = false;
+ lock (_cacheLock)
+ {
+ if (!_sceneMediaCache.TryGetValue(sceneName, out var cached) ||
+ cached.Status?.MediaCursor != status.MediaCursor ||
+ cached.Status?.MediaState != status.MediaState)
+ {
+ _sceneMediaCache[sceneName] = sceneMediaStatus;
+ hasChanged = true;
+ }
+ }
+
+ // Broadcast if changed
+ if (hasChanged)
+ {
+ MediaStatusChanged?.Invoke(this, sceneMediaStatus);
+ }
+
+ break; // Found media source, stop looking
+ }
+ }
+ catch
+ {
+ // Not a media source, continue
+ }
+ }
+ }
+
+ // If no media source found, clear cache for this scene
+ if (mediaSource == null)
+ {
+ lock (_cacheLock)
+ {
+ if (_sceneMediaCache.ContainsKey(sceneName))
+ {
+ _sceneMediaCache.Remove(sceneName);
+
+ // Broadcast null status
+ MediaStatusChanged?.Invoke(this, new SceneMediaStatus
+ {
+ SceneName = sceneName,
+ MediaInputName = null,
+ Status = null
+ });
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating media status for scene {SceneName}", sceneName);
+ }
+ }
+
+ public Dictionary GetAllMediaStatus()
+ {
+ lock (_cacheLock)
+ {
+ return new Dictionary(_sceneMediaCache);
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Services/OBSService.cs b/API/ThriveStreamController.Core/Services/OBSService.cs
new file mode 100644
index 0000000..ba4ee99
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/OBSService.cs
@@ -0,0 +1,1010 @@
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json.Linq;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Service for managing OBS WebSocket connections and operations.
+ /// Implements the IOBSService interface to provide OBS control functionality.
+ /// Uses custom WebSocket client designed for ASP.NET Core.
+ ///
+ public class OBSService : IOBSService, IDisposable
+ {
+ private readonly ILogger _logger;
+ private readonly OBSWebSocketClient _client;
+ private OBSConnectionStatus _connectionStatus;
+ private readonly object _statusLock = new object();
+ private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(1, 1);
+ private bool _isConnecting = false;
+ private int _eventCounter = 0;
+ private readonly Dictionary _inputMuteStates = new Dictionary();
+ private readonly object _muteLock = new object();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger instance for logging operations.
+ /// The logger instance for the WebSocket client.
+ public OBSService(ILogger logger, ILogger clientLogger)
+ {
+ _logger = logger;
+ _client = new OBSWebSocketClient(clientLogger);
+ _connectionStatus = new OBSConnectionStatus
+ {
+ IsConnected = false,
+ ServerUrl = string.Empty
+ };
+
+ // Subscribe to client events
+ _client.Connected += OnConnected;
+ _client.Disconnected += OnDisconnected;
+ _client.EventReceived += OnEventReceived;
+ }
+
+ ///
+ /// Gets the current connection status.
+ ///
+ public OBSConnectionStatus ConnectionStatus => _connectionStatus;
+
+ ///
+ /// Event raised when the connection status changes.
+ ///
+ public event EventHandler? ConnectionStatusChanged;
+
+ ///
+ /// Event raised when the active scene changes in OBS.
+ ///
+ public event EventHandler? SceneChanged;
+
+ ///
+ /// Event raised when the streaming status changes in OBS.
+ ///
+ public event EventHandler? StreamingStatusChanged;
+
+ ///
+ /// Event raised when audio volume meters are updated (every 50ms).
+ ///
+ public event EventHandler? VolumeMetersChanged;
+
+ ///
+ /// Connects to the OBS WebSocket server.
+ ///
+ /// The WebSocket server URL (e.g., "ws://localhost:4455").
+ /// The WebSocket server password (optional).
+ /// A task that represents the asynchronous connect operation. Returns true if connection was successful.
+ public async Task ConnectAsync(string url, string? password = null)
+ {
+ // Check if already connected
+ if (_client.IsConnected)
+ {
+ _logger.LogInformation("Already connected to OBS");
+ return true;
+ }
+
+ // Check if already connecting
+ if (_isConnecting)
+ {
+ _logger.LogWarning("Connection attempt already in progress");
+ return false;
+ }
+
+ // Acquire lock to prevent multiple simultaneous connection attempts
+ await _connectionLock.WaitAsync();
+ try
+ {
+ // Double-check after acquiring lock
+ if (_client.IsConnected)
+ {
+ _logger.LogInformation("Already connected to OBS (after lock)");
+ return true;
+ }
+
+ if (_isConnecting)
+ {
+ _logger.LogWarning("Connection attempt already in progress (after lock)");
+ return false;
+ }
+
+ _isConnecting = true;
+ _logger.LogInformation("Attempting to connect to OBS at {Url}", url);
+
+ lock (_statusLock)
+ {
+ _connectionStatus.ServerUrl = url;
+ _connectionStatus.LastError = null;
+ }
+
+ try
+ {
+ // Attempt to connect using our custom WebSocket client
+ var success = await _client.ConnectAsync(url, password);
+
+ if (success)
+ {
+ lock (_statusLock)
+ {
+ _connectionStatus.IsConnected = true;
+ _connectionStatus.ConnectedAt = DateTime.UtcNow;
+ _connectionStatus.LastError = null;
+ }
+ _logger.LogInformation("Successfully connected to OBS");
+ ConnectionStatusChanged?.Invoke(this, _connectionStatus);
+ return true;
+ }
+ else
+ {
+ _logger.LogWarning("Connection attempt failed");
+ lock (_statusLock)
+ {
+ _connectionStatus.LastError = "Connection attempt failed";
+ }
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error connecting to OBS: {Message}", ex.Message);
+ lock (_statusLock)
+ {
+ _connectionStatus.LastError = $"Error: {ex.Message}";
+ }
+ throw;
+ }
+ }
+ finally
+ {
+ _isConnecting = false;
+ _connectionLock.Release();
+ }
+ }
+
+ ///
+ /// Disconnects from the OBS WebSocket server.
+ ///
+ /// A task that represents the asynchronous disconnect operation.
+ public async Task DisconnectAsync()
+ {
+ try
+ {
+ _logger.LogInformation("Disconnecting from OBS...");
+ await _client.DisconnectAsync();
+
+ lock (_statusLock)
+ {
+ _connectionStatus.IsConnected = false;
+ _connectionStatus.LastError = null;
+ }
+
+ ConnectionStatusChanged?.Invoke(this, _connectionStatus);
+ _logger.LogInformation("Disconnected from OBS");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error disconnecting from OBS");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets a list of all scenes from OBS.
+ ///
+ /// A list of OBS scenes.
+ public async Task> GetScenesAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot get scenes: Not connected to OBS");
+ return [];
+ }
+
+ // Send GetSceneList request
+ var response = await _client.SendRequestAsync("GetSceneList");
+ if (response == null)
+ {
+ _logger.LogWarning("GetSceneList returned null");
+ return [];
+ }
+
+ // Check if request was successful
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("GetSceneList failed: Code={Code}, Comment={Comment}", code, comment);
+ return [];
+ }
+
+ // Parse response data
+ var responseData = response["responseData"] as JObject;
+ if (responseData == null)
+ {
+ _logger.LogWarning("GetSceneList response has no data");
+ return [];
+ }
+
+ var currentProgramSceneName = responseData["currentProgramSceneName"]?.Value();
+ var scenesArray = responseData["scenes"] as JArray;
+
+ if (scenesArray == null || scenesArray.Count == 0)
+ {
+ _logger.LogWarning("GetSceneList returned no scenes");
+ return [];
+ }
+
+ var scenes = new List();
+ foreach (var sceneToken in scenesArray)
+ {
+ var sceneObj = sceneToken as JObject;
+ if (sceneObj == null) continue;
+
+ var sceneName = sceneObj["sceneName"]?.Value();
+ var sceneIndex = sceneObj["sceneIndex"]?.Value() ?? 0;
+
+ if (!string.IsNullOrEmpty(sceneName))
+ {
+ scenes.Add(new OBSScene
+ {
+ Name = sceneName,
+ IsActive = sceneName == currentProgramSceneName,
+ Index = sceneIndex
+ });
+ }
+ }
+
+ _logger.LogDebug("Retrieved {Count} scenes from OBS", scenes.Count);
+ return scenes;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting scenes from OBS: {Message}", ex.Message);
+ return [];
+ }
+ }
+
+ ///
+ /// Switches to the specified scene in OBS.
+ ///
+ /// The name of the scene to switch to.
+ /// A task that represents the asynchronous operation. Returns true if successful.
+ public async Task SwitchSceneAsync(string sceneName)
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot switch scene: Not connected to OBS");
+ return false;
+ }
+
+ _logger.LogInformation("Switching to scene: {SceneName}", sceneName);
+
+ var requestData = new JObject
+ {
+ ["sceneName"] = sceneName
+ };
+
+ var response = await _client.SendRequestAsync("SetCurrentProgramScene", requestData);
+
+ if (response == null)
+ {
+ _logger.LogWarning("SetCurrentProgramScene returned null");
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (result)
+ {
+ _logger.LogInformation("Successfully switched to scene: {SceneName}", sceneName);
+ SceneChanged?.Invoke(this, sceneName);
+ }
+ else
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("SetCurrentProgramScene failed: Code={Code}, Comment={Comment}", code, comment);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error switching scene: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the current streaming status from OBS.
+ ///
+ /// The current streaming status.
+ public async Task GetStreamingStatusAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot get streaming status: Not connected to OBS");
+ return new StreamingStatus { IsStreaming = false };
+ }
+
+ var response = await _client.SendRequestAsync("GetStreamStatus");
+
+ if (response == null)
+ {
+ return new StreamingStatus { IsStreaming = false };
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ return new StreamingStatus { IsStreaming = false };
+ }
+
+ var responseData = response["responseData"] as JObject;
+ var outputActive = responseData?["outputActive"]?.Value() ?? false;
+ var outputDuration = responseData?["outputDuration"]?.Value() ?? 0;
+
+ return new StreamingStatus
+ {
+ IsStreaming = outputActive,
+ StreamDurationSeconds = (int)(outputDuration / 1000)
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting streaming status: {Message}", ex.Message);
+ return new StreamingStatus { IsStreaming = false };
+ }
+ }
+
+ ///
+ /// Starts streaming in OBS.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if successful.
+ public async Task StartStreamingAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot start streaming: Not connected to OBS");
+ return false;
+ }
+
+ _logger.LogInformation("Starting stream...");
+
+ var response = await _client.SendRequestAsync("StartStream");
+
+ if (response == null)
+ {
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (result)
+ {
+ _logger.LogInformation("Successfully started streaming");
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting stream: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Stops streaming in OBS.
+ ///
+ /// A task that represents the asynchronous operation. Returns true if successful.
+ public async Task StopStreamingAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot stop streaming: Not connected to OBS");
+ return false;
+ }
+
+ _logger.LogInformation("Stopping stream...");
+
+ var response = await _client.SendRequestAsync("StopStream");
+
+ if (response == null)
+ {
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (result)
+ {
+ _logger.LogInformation("Successfully stopped streaming");
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error stopping stream: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the current connection status.
+ ///
+ /// The current connection status.
+ public OBSConnectionStatus GetConnectionStatus()
+ {
+ lock (_statusLock)
+ {
+ return _connectionStatus;
+ }
+ }
+
+
+ private void OnConnected(object? sender, EventArgs e)
+ {
+ _logger.LogInformation("OBS WebSocket connected event received");
+
+ // Query initial mute states for all inputs
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(500); // Small delay to ensure connection is fully established
+ await QueryAllInputMuteStatesAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to query initial input mute states");
+ }
+ });
+ }
+
+ private void OnDisconnected(object? sender, EventArgs e)
+ {
+ _logger.LogWarning("OBS WebSocket disconnected event received");
+
+ lock (_statusLock)
+ {
+ _connectionStatus.IsConnected = false;
+ }
+
+ // Clear mute states on disconnect
+ lock (_muteLock)
+ {
+ _inputMuteStates.Clear();
+ }
+
+ ConnectionStatusChanged?.Invoke(this, _connectionStatus);
+ }
+
+ private void OnEventReceived(object? sender, JObject eventData)
+ {
+ _eventCounter++;
+ var eventType = eventData["eventType"]?.Value();
+ _logger.LogDebug("Received OBS event: {EventType}", eventType);
+
+ // Handle specific events
+ switch (eventType)
+ {
+ case "CurrentProgramSceneChanged":
+ var sceneName = eventData["eventData"]?["sceneName"]?.Value();
+ if (!string.IsNullOrEmpty(sceneName))
+ {
+ SceneChanged?.Invoke(this, sceneName);
+ }
+ break;
+
+ case "StreamStateChanged":
+ var outputActive = eventData["eventData"]?["outputActive"]?.Value() ?? false;
+ StreamingStatusChanged?.Invoke(this, new StreamingStatus
+ {
+ IsStreaming = outputActive,
+ StreamDurationSeconds = 0
+ });
+ break;
+
+ case "InputMuteStateChanged":
+ var mutedInputName = eventData["eventData"]?["inputName"]?.Value();
+ var mutedInputUuid = eventData["eventData"]?["inputUuid"]?.Value();
+ var inputMuted = eventData["eventData"]?["inputMuted"]?.Value() ?? false;
+
+ if (!string.IsNullOrEmpty(mutedInputUuid))
+ {
+ lock (_muteLock)
+ {
+ _inputMuteStates[mutedInputUuid] = inputMuted;
+ }
+ _logger.LogDebug("Input mute state changed: {InputName} ({InputUuid}) = {Muted}",
+ mutedInputName, mutedInputUuid, inputMuted);
+ }
+ break;
+
+ case "InputVolumeMeters":
+ var inputsArray = eventData["eventData"]?["inputs"] as JArray;
+
+ // Log every 100th event to avoid spam (events fire every 50ms)
+ if (_eventCounter % 100 == 0)
+ {
+ _logger.LogDebug("InputVolumeMeters event received. Inputs count: {Count}", inputsArray?.Count ?? 0);
+ if (inputsArray != null && inputsArray.Count > 0)
+ {
+ _logger.LogDebug("Input names: {Names}", string.Join(", ", inputsArray.Select(i => i["inputName"]?.Value() ?? "unknown")));
+ }
+ }
+
+ if (inputsArray != null)
+ {
+ var volumeMetersData = new InputVolumeMetersData();
+ foreach (var inputToken in inputsArray)
+ {
+ var inputObj = inputToken as JObject;
+ if (inputObj == null) continue;
+
+ var inputName = inputObj["inputName"]?.Value();
+ var inputUuid = inputObj["inputUuid"]?.Value();
+ var levelsArray = inputObj["inputLevelsMul"] as JArray;
+
+ if (!string.IsNullOrEmpty(inputName) && levelsArray != null)
+ {
+ // inputLevelsMul is a 2D array: [[channel1_peak, channel1_magnitude, channel1_input_peak], [channel2_peak, ...]]
+ // We'll extract the peak value (index 2) from each channel
+ var channelLevels = new List();
+ foreach (var channelToken in levelsArray)
+ {
+ var channelArray = channelToken as JArray;
+ if (channelArray != null && channelArray.Count >= 3)
+ {
+ // Use the input peak value (index 2) which represents the peak level
+ channelLevels.Add(channelArray[2].Value());
+ }
+ }
+
+ if (channelLevels.Count > 0)
+ {
+ // Get mute state for this input
+ bool isMuted = false;
+ if (!string.IsNullOrEmpty(inputUuid))
+ {
+ lock (_muteLock)
+ {
+ _inputMuteStates.TryGetValue(inputUuid, out isMuted);
+ }
+ }
+
+ // Only include non-muted sources
+ if (!isMuted)
+ {
+ var volumeMeter = new InputVolumeMeter
+ {
+ InputName = inputName,
+ InputUuid = inputUuid ?? string.Empty,
+ InputLevelsMul = channelLevels,
+ InputMuted = isMuted
+ };
+ volumeMetersData.Inputs.Add(volumeMeter);
+ }
+ }
+ }
+ }
+
+ if (volumeMetersData.Inputs.Count > 0)
+ {
+ VolumeMetersChanged?.Invoke(this, volumeMetersData);
+ }
+ }
+ break;
+ }
+ }
+
+ ///
+ /// Gets the list of scene items for a specific scene.
+ ///
+ /// The name of the scene to get items for.
+ /// A list of scene items.
+ public async Task> GetSceneItemsAsync(string sceneName)
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot get scene items: Not connected to OBS");
+ return new List();
+ }
+
+ _logger.LogDebug("Getting scene items for scene: {SceneName}", sceneName);
+
+ var requestData = new JObject
+ {
+ ["sceneName"] = sceneName
+ };
+
+ var response = await _client.SendRequestAsync("GetSceneItemList", requestData);
+
+ if (response == null)
+ {
+ _logger.LogWarning("GetSceneItemList returned null");
+ return new List();
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("GetSceneItemList failed: Code={Code}, Comment={Comment}", code, comment);
+ return new List();
+ }
+
+ var responseData = response["responseData"] as JObject;
+ var sceneItemsArray = responseData?["sceneItems"] as JArray;
+
+ if (sceneItemsArray == null)
+ {
+ _logger.LogWarning("No scene items found in response");
+ return new List();
+ }
+
+ var sceneItems = new List();
+ foreach (var item in sceneItemsArray)
+ {
+ var sceneItem = new SceneItem
+ {
+ SceneItemId = item["sceneItemId"]?.Value() ?? 0,
+ SceneItemIndex = item["sceneItemIndex"]?.Value() ?? 0,
+ SourceName = item["sourceName"]?.Value() ?? string.Empty,
+ SourceUuid = item["sourceUuid"]?.Value() ?? string.Empty,
+ SourceType = item["sourceType"]?.Value() ?? string.Empty,
+ SceneItemEnabled = item["sceneItemEnabled"]?.Value() ?? false
+ };
+ sceneItems.Add(sceneItem);
+ }
+
+ _logger.LogDebug("Found {Count} scene items", sceneItems.Count);
+ return sceneItems;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting scene items: {Message}", ex.Message);
+ return new List();
+ }
+ }
+
+ ///
+ /// Gets the media input status for a specific input.
+ ///
+ /// The name of the media input.
+ /// The media input status.
+ public async Task GetMediaInputStatusAsync(string inputName)
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot get media input status: Not connected to OBS");
+ return null;
+ }
+
+ _logger.LogDebug("Getting media input status for: {InputName}", inputName);
+
+ var requestData = new JObject
+ {
+ ["inputName"] = inputName
+ };
+
+ var response = await _client.SendRequestAsync("GetMediaInputStatus", requestData);
+
+ if (response == null)
+ {
+ _logger.LogWarning("GetMediaInputStatus returned null");
+ return null;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("GetMediaInputStatus failed: Code={Code}, Comment={Comment}", code, comment);
+ return null;
+ }
+
+ var responseData = response["responseData"] as JObject;
+
+ if (responseData == null)
+ {
+ _logger.LogWarning("No response data found");
+ return null;
+ }
+
+ var mediaStatus = new MediaInputStatus
+ {
+ MediaState = responseData["mediaState"]?.Value() ?? string.Empty,
+ MediaDuration = responseData["mediaDuration"]?.Value(),
+ MediaCursor = responseData["mediaCursor"]?.Value()
+ };
+
+ _logger.LogDebug("Media status: State={State}, Duration={Duration}ms, Cursor={Cursor}ms",
+ mediaStatus.MediaState, mediaStatus.MediaDuration, mediaStatus.MediaCursor);
+
+ return mediaStatus;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting media input status: {Message}", ex.Message);
+ return null;
+ }
+ }
+
+ ///
+ /// Gets the status of the OBS Virtual Camera.
+ ///
+ /// True if the virtual camera is active, false otherwise.
+ public async Task GetVirtualCamStatusAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot get virtual camera status: Not connected to OBS");
+ return false;
+ }
+
+ var response = await _client.SendRequestAsync("GetVirtualCamStatus");
+
+ if (response == null)
+ {
+ _logger.LogWarning("GetVirtualCamStatus returned null");
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("GetVirtualCamStatus failed: Code={Code}, Comment={Comment}", code, comment);
+ return false;
+ }
+
+ var responseData = response["responseData"] as JObject;
+ var outputActive = responseData?["outputActive"]?.Value() ?? false;
+
+ _logger.LogDebug("Virtual camera status: {Status}", outputActive ? "Active" : "Inactive");
+ return outputActive;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting virtual camera status: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Starts the OBS Virtual Camera.
+ ///
+ /// True if successful, false otherwise.
+ public async Task StartVirtualCamAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot start virtual camera: Not connected to OBS");
+ return false;
+ }
+
+ _logger.LogInformation("Starting OBS Virtual Camera...");
+
+ var response = await _client.SendRequestAsync("StartVirtualCam");
+
+ if (response == null)
+ {
+ _logger.LogWarning("StartVirtualCam returned null");
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (result)
+ {
+ _logger.LogInformation("Successfully started OBS Virtual Camera");
+ }
+ else
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("StartVirtualCam failed: Code={Code}, Comment={Comment}", code, comment);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting virtual camera: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Stops the OBS Virtual Camera.
+ ///
+ /// True if successful, false otherwise.
+ public async Task StopVirtualCamAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot stop virtual camera: Not connected to OBS");
+ return false;
+ }
+
+ _logger.LogInformation("Stopping OBS Virtual Camera...");
+
+ var response = await _client.SendRequestAsync("StopVirtualCam");
+
+ if (response == null)
+ {
+ _logger.LogWarning("StopVirtualCam returned null");
+ return false;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (result)
+ {
+ _logger.LogInformation("Successfully stopped OBS Virtual Camera");
+ }
+ else
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("StopVirtualCam failed: Code={Code}, Comment={Comment}", code, comment);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error stopping virtual camera: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Queries the mute state for all inputs and populates the _inputMuteStates dictionary
+ ///
+ private async Task QueryAllInputMuteStatesAsync()
+ {
+ try
+ {
+ if (!_client.IsConnected)
+ {
+ _logger.LogWarning("Cannot query input mute states: Not connected to OBS");
+ return;
+ }
+
+ _logger.LogInformation("Querying initial mute states for all inputs");
+
+ // First, get the list of all inputs
+ var response = await _client.SendRequestAsync("GetInputList", null);
+
+ if (response == null)
+ {
+ _logger.LogWarning("GetInputList returned null");
+ return;
+ }
+
+ var requestStatus = response["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+
+ if (!result)
+ {
+ var code = requestStatus?["code"]?.Value() ?? 0;
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("GetInputList failed: Code={Code}, Comment={Comment}", code, comment);
+ return;
+ }
+
+ var responseData = response["responseData"] as JObject;
+ var inputsArray = responseData?["inputs"] as JArray;
+
+ if (inputsArray == null || inputsArray.Count == 0)
+ {
+ _logger.LogInformation("No inputs found");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} inputs, querying mute states", inputsArray.Count);
+
+ // Query mute state for each input
+ foreach (var inputToken in inputsArray)
+ {
+ var inputObj = inputToken as JObject;
+ if (inputObj == null) continue;
+
+ var inputUuid = inputObj["inputUuid"]?.Value();
+ var inputName = inputObj["inputName"]?.Value();
+
+ if (string.IsNullOrEmpty(inputUuid)) continue;
+
+ try
+ {
+ // Query the mute state for this input
+ var requestData = new JObject
+ {
+ ["inputUuid"] = inputUuid
+ };
+ var muteResponse = await _client.SendRequestAsync("GetInputMute", requestData);
+
+ if (muteResponse != null)
+ {
+ var muteRequestStatus = muteResponse["requestStatus"] as JObject;
+ var muteResult = muteRequestStatus?["result"]?.Value() ?? false;
+
+ if (muteResult)
+ {
+ var muteResponseData = muteResponse["responseData"] as JObject;
+ var inputMuted = muteResponseData?["inputMuted"]?.Value() ?? false;
+
+ lock (_muteLock)
+ {
+ _inputMuteStates[inputUuid] = inputMuted;
+ }
+
+ _logger.LogDebug("Initial mute state for {InputName} ({InputUuid}): {Muted}",
+ inputName, inputUuid, inputMuted);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to query mute state for input {InputName} ({InputUuid})",
+ inputName, inputUuid);
+ }
+ }
+
+ _logger.LogInformation("Completed querying initial mute states for {Count} inputs", inputsArray.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error querying all input mute states: {Message}", ex.Message);
+ }
+ }
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ _connectionLock?.Dispose();
+ }
+ }
+}
diff --git a/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs b/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs
new file mode 100644
index 0000000..a41d75e
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs
@@ -0,0 +1,466 @@
+using System;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Security.Cryptography;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Custom OBS WebSocket client designed for ASP.NET Core
+ /// Implements the OBS WebSocket v5.x protocol
+ ///
+ public class OBSWebSocketClient : IDisposable
+ {
+ private readonly ILogger _logger;
+ private ClientWebSocket? _webSocket;
+ private CancellationTokenSource? _cancellationTokenSource;
+ private Task? _receiveTask;
+ private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
+ private readonly Dictionary> _pendingRequests = new();
+ private readonly object _requestLock = new object();
+
+ // Connection state
+ private string? _url;
+ private string? _password;
+ private bool _isIdentified;
+
+ // Events
+ public event EventHandler? Connected;
+ public event EventHandler? Disconnected;
+ public event EventHandler? EventReceived;
+
+ public bool IsConnected => _webSocket?.State == WebSocketState.Open && _isIdentified;
+
+ public OBSWebSocketClient(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Connect to OBS WebSocket server
+ ///
+ public async Task ConnectAsync(string url, string? password = null, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Connecting to OBS WebSocket at {Url}", url);
+
+ _url = url;
+ _password = password;
+ _isIdentified = false;
+
+ // Create new WebSocket
+ _webSocket = new ClientWebSocket();
+ _webSocket.Options.AddSubProtocol("obswebsocket.json");
+
+ // Connect
+ await _webSocket.ConnectAsync(new Uri(url), cancellationToken);
+ _logger.LogInformation("WebSocket connected, waiting for Hello message...");
+
+ // Start receive loop
+ _cancellationTokenSource = new CancellationTokenSource();
+ _receiveTask = Task.Run(() => ReceiveLoopAsync(_cancellationTokenSource.Token), _cancellationTokenSource.Token);
+
+ // Wait for identification to complete (with timeout)
+ var identifyTimeout = Task.Delay(5000, cancellationToken);
+ while (!_isIdentified && !cancellationToken.IsCancellationRequested)
+ {
+ if (await Task.WhenAny(Task.Delay(100, cancellationToken), identifyTimeout) == identifyTimeout)
+ {
+ _logger.LogError("Timeout waiting for identification");
+ await DisconnectAsync();
+ return false;
+ }
+ }
+
+ if (_isIdentified)
+ {
+ _logger.LogInformation("Successfully connected and identified with OBS");
+ Connected?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error connecting to OBS WebSocket");
+ await DisconnectAsync();
+ return false;
+ }
+ }
+
+ ///
+ /// Disconnect from OBS WebSocket server
+ ///
+ public async Task DisconnectAsync()
+ {
+ try
+ {
+ _isIdentified = false;
+
+ // Cancel receive loop
+ _cancellationTokenSource?.Cancel();
+
+ // Close WebSocket
+ if (_webSocket?.State == WebSocketState.Open)
+ {
+ await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnecting", CancellationToken.None);
+ }
+
+ // Wait for receive task to complete
+ if (_receiveTask != null)
+ {
+ await _receiveTask;
+ }
+
+ _webSocket?.Dispose();
+ _webSocket = null;
+
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = null;
+
+ Disconnected?.Invoke(this, EventArgs.Empty);
+ _logger.LogInformation("Disconnected from OBS WebSocket");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error disconnecting from OBS WebSocket");
+ }
+ }
+
+ ///
+ /// Send a request to OBS and wait for response
+ ///
+ public async Task SendRequestAsync(string requestType, JObject? requestData = null, int timeoutMs = 10000)
+ {
+ if (!IsConnected)
+ {
+ _logger.LogWarning("Cannot send request: Not connected");
+ return null;
+ }
+
+ var requestId = Guid.NewGuid().ToString();
+ var tcs = new TaskCompletionSource();
+
+ // Register pending request
+ lock (_requestLock)
+ {
+ _pendingRequests[requestId] = tcs;
+ }
+
+ try
+ {
+ // Build request message (OpCode 6 = Request)
+ var request = new JObject
+ {
+ ["op"] = 6,
+ ["d"] = new JObject
+ {
+ ["requestType"] = requestType,
+ ["requestId"] = requestId,
+ ["requestData"] = requestData ?? new JObject()
+ }
+ };
+
+ _logger.LogDebug("Sending request: {RequestType} (ID: {RequestId})", requestType, requestId);
+
+ // Send request
+ await SendMessageAsync(request);
+
+ // Wait for response with timeout
+ using var cts = new CancellationTokenSource(timeoutMs);
+ cts.Token.Register(() => tcs.TrySetCanceled());
+
+ var response = await tcs.Task;
+ return response;
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogWarning("Request timeout: {RequestType}", requestType);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error sending request: {RequestType}", requestType);
+ return null;
+ }
+ finally
+ {
+ // Remove pending request
+ lock (_requestLock)
+ {
+ _pendingRequests.Remove(requestId);
+ }
+ }
+ }
+
+ private async Task SendMessageAsync(JObject message)
+ {
+ await _sendLock.WaitAsync();
+ try
+ {
+ if (_webSocket?.State != WebSocketState.Open)
+ {
+ throw new InvalidOperationException("WebSocket is not open");
+ }
+
+ var json = message.ToString(Formatting.None);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ await _webSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ finally
+ {
+ _sendLock.Release();
+ }
+ }
+
+ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
+ {
+ var buffer = new byte[8192];
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested && _webSocket?.State == WebSocketState.Open)
+ {
+ var messageBuilder = new StringBuilder();
+
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await _webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ _logger.LogWarning("WebSocket close message received. Status: {Status}, Description: {Description}",
+ result.CloseStatus,
+ result.CloseStatusDescription);
+ await DisconnectAsync();
+ return;
+ }
+
+ messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
+ }
+ while (!result.EndOfMessage);
+
+ var messageJson = messageBuilder.ToString();
+ await ProcessMessageAsync(messageJson);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Receive loop cancelled");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in receive loop");
+ await DisconnectAsync();
+ }
+ }
+
+ private async Task ProcessMessageAsync(string messageJson)
+ {
+ try
+ {
+ var message = JObject.Parse(messageJson);
+ var opCode = message["op"]?.Value() ?? -1;
+
+ _logger.LogDebug("Received message with OpCode: {OpCode}", opCode);
+
+ switch (opCode)
+ {
+ case 0: // Hello
+ await HandleHelloAsync(message["d"] as JObject);
+ break;
+
+ case 2: // Identified
+ HandleIdentified(message["d"] as JObject);
+ break;
+
+ case 5: // Event
+ HandleEvent(message["d"] as JObject);
+ break;
+
+ case 7: // RequestResponse
+ HandleRequestResponse(message["d"] as JObject);
+ break;
+
+ default:
+ _logger.LogWarning("Unknown OpCode: {OpCode}", opCode);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing message: {Message}", messageJson);
+ }
+ }
+
+ private async Task HandleHelloAsync(JObject? data)
+ {
+ if (data == null) return;
+
+ _logger.LogInformation("Received Hello from OBS");
+
+ var rpcVersion = data["rpcVersion"]?.Value() ?? 1;
+ var authentication = data["authentication"] as JObject;
+
+ // Build Identify message (OpCode 1)
+ // Event subscription bitmask:
+ // General (1 << 0) = 1
+ // Config (1 << 1) = 2
+ // Scenes (1 << 2) = 4
+ // Inputs (1 << 3) = 8
+ // Transitions (1 << 4) = 16
+ // Filters (1 << 5) = 32
+ // Outputs (1 << 6) = 64
+ // SceneItems (1 << 7) = 128
+ // MediaInputs (1 << 8) = 256
+ // Vendors (1 << 9) = 512
+ // Ui (1 << 10) = 1024
+ // All = 2047 (sum of all above)
+ // InputVolumeMeters (1 << 16) = 65536 (high-volume event for audio levels)
+ var eventSubscriptions = 2047 + 65536; // Subscribe to all events including InputVolumeMeters
+ var identifyData = new JObject
+ {
+ ["rpcVersion"] = rpcVersion,
+ ["eventSubscriptions"] = eventSubscriptions
+ };
+
+ // Handle authentication if required
+ if (authentication != null)
+ {
+ if (string.IsNullOrEmpty(_password))
+ {
+ _logger.LogWarning("OBS requires authentication but no password was provided");
+ }
+ else
+ {
+ var challenge = authentication["challenge"]?.Value();
+ var salt = authentication["salt"]?.Value();
+
+ if (challenge != null && salt != null)
+ {
+ _logger.LogInformation("Generating authentication string (password length: {Length})", _password.Length);
+ var authString = GenerateAuthString(_password, salt, challenge);
+ identifyData["authentication"] = authString;
+ _logger.LogInformation("Authentication required, sending credentials");
+ }
+ else
+ {
+ _logger.LogWarning("Authentication required but challenge or salt is missing");
+ }
+ }
+ }
+ else if (!string.IsNullOrEmpty(_password))
+ {
+ _logger.LogInformation("Password provided but OBS does not require authentication");
+ }
+ else
+ {
+ _logger.LogInformation("No authentication required");
+ }
+
+ var identify = new JObject
+ {
+ ["op"] = 1,
+ ["d"] = identifyData
+ };
+
+ _logger.LogInformation("Sending Identify message: {Message}", identify.ToString(Formatting.None));
+ await SendMessageAsync(identify);
+ }
+
+ private void HandleIdentified(JObject? data)
+ {
+ if (data == null) return;
+
+ var negotiatedRpcVersion = data["negotiatedRpcVersion"]?.Value() ?? 1;
+ _logger.LogInformation("Identified with OBS, RPC version: {Version}", negotiatedRpcVersion);
+
+ _isIdentified = true;
+ }
+
+ private void HandleEvent(JObject? data)
+ {
+ if (data == null) return;
+
+ var eventType = data["eventType"]?.Value();
+ _logger.LogDebug("Received event: {EventType}", eventType);
+
+ EventReceived?.Invoke(this, data);
+ }
+
+ private void HandleRequestResponse(JObject? data)
+ {
+ if (data == null) return;
+
+ var requestId = data["requestId"]?.Value();
+ if (string.IsNullOrEmpty(requestId)) return;
+
+ var requestStatus = data["requestStatus"] as JObject;
+ var result = requestStatus?["result"]?.Value() ?? false;
+ var code = requestStatus?["code"]?.Value() ?? 0;
+
+ _logger.LogDebug("Received response for request {RequestId}: Result={Result}, Code={Code}", requestId, result, code);
+
+ // Find and complete the pending request
+ TaskCompletionSource? tcs = null;
+ lock (_requestLock)
+ {
+ if (_pendingRequests.TryGetValue(requestId, out tcs))
+ {
+ _pendingRequests.Remove(requestId);
+ }
+ }
+
+ if (tcs != null)
+ {
+ if (result)
+ {
+ tcs.SetResult(data);
+ }
+ else
+ {
+ var comment = requestStatus?["comment"]?.Value();
+ _logger.LogWarning("Request failed: Code={Code}, Comment={Comment}", code, comment);
+ tcs.SetResult(data); // Still return the response so caller can handle the error
+ }
+ }
+ }
+
+ private string GenerateAuthString(string password, string salt, string challenge)
+ {
+ // Step 1: Concatenate password + salt
+ var passwordSalt = password + salt;
+
+ // Step 2: Generate SHA256 hash and base64 encode
+ using var sha256 = SHA256.Create();
+ var passwordSaltBytes = Encoding.UTF8.GetBytes(passwordSalt);
+ var passwordSaltHash = sha256.ComputeHash(passwordSaltBytes);
+ var base64Secret = Convert.ToBase64String(passwordSaltHash);
+
+ // Step 3: Concatenate base64Secret + challenge
+ var secretChallenge = base64Secret + challenge;
+
+ // Step 4: Generate SHA256 hash and base64 encode
+ var secretChallengeBytes = Encoding.UTF8.GetBytes(secretChallenge);
+ var secretChallengeHash = sha256.ComputeHash(secretChallengeBytes);
+ var authString = Convert.ToBase64String(secretChallengeHash);
+
+ return authString;
+ }
+
+ public void Dispose()
+ {
+ DisconnectAsync().GetAwaiter().GetResult();
+ _sendLock.Dispose();
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Services/YouTubeAuthService.cs b/API/ThriveStreamController.Core/Services/YouTubeAuthService.cs
new file mode 100644
index 0000000..c12484e
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/YouTubeAuthService.cs
@@ -0,0 +1,312 @@
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.Auth.OAuth2.Flows;
+using Google.Apis.Auth.OAuth2.Responses;
+using Google.Apis.Services;
+using Google.Apis.Util.Store;
+using Google.Apis.YouTube.v3;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Data;
+using ThriveStreamController.Data.Entities;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Implementation of YouTube OAuth 2.0 authentication service.
+ ///
+ public class YouTubeAuthService : IYouTubeAuthService
+ {
+ private readonly YouTubeConfiguration _config;
+ private readonly ApplicationDbContext _dbContext;
+ private readonly ICredentialEncryptionService _encryptionService;
+ private readonly ILogger _logger;
+ private readonly GoogleAuthorizationCodeFlow _flow;
+
+ public YouTubeAuthService(
+ IOptions config,
+ ApplicationDbContext dbContext,
+ ICredentialEncryptionService encryptionService,
+ ILogger logger)
+ {
+ _config = config.Value;
+ _dbContext = dbContext;
+ _encryptionService = encryptionService;
+ _logger = logger;
+
+ // Initialize the OAuth flow
+ _flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
+ {
+ ClientSecrets = new ClientSecrets
+ {
+ ClientId = _config.ClientId,
+ ClientSecret = _config.ClientSecret
+ },
+ Scopes = _config.Scopes,
+ DataStore = new NullDataStore() // We'll handle storage ourselves
+ });
+ }
+
+ ///
+ public string GetAuthorizationUrl(string? state = null)
+ {
+ if (!_config.IsValid())
+ {
+ throw new InvalidOperationException("YouTube configuration is not valid. ClientId and ClientSecret are required.");
+ }
+
+ var codeRequestUrl = _flow.CreateAuthorizationCodeRequest(_config.RedirectUri);
+ codeRequestUrl.State = state ?? Guid.NewGuid().ToString();
+
+ var authUrl = codeRequestUrl.Build();
+ _logger.LogInformation("Generated YouTube authorization URL");
+
+ return authUrl.ToString();
+ }
+
+ ///
+ public async Task ExchangeAuthorizationCodeAsync(string authorizationCode, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Exchanging authorization code for tokens");
+
+ // Exchange the authorization code for tokens
+ var tokenResponse = await _flow.ExchangeCodeForTokenAsync(
+ "user", // User ID (we only have one user for this app)
+ authorizationCode,
+ _config.RedirectUri,
+ cancellationToken);
+
+ if (tokenResponse == null)
+ {
+ _logger.LogError("Failed to exchange authorization code: null response");
+ return false;
+ }
+
+ // Store the tokens in the database
+ await StoreTokensAsync(tokenResponse, cancellationToken);
+
+ _logger.LogInformation("Successfully exchanged authorization code and stored tokens");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error exchanging authorization code");
+ return false;
+ }
+ }
+
+ ///
+ public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Get stored credentials
+ var credential = await GetStoredCredentialAsync(cancellationToken);
+
+ if (credential == null)
+ {
+ throw new InvalidOperationException("No YouTube credentials found. Please authorize the application first.");
+ }
+
+ // Decrypt the refresh token
+ var refreshToken = await _encryptionService.DecryptAsync(credential.EncryptedRefreshToken!);
+
+ // Check if access token is still valid
+ if (credential.ExpiresAt.HasValue && credential.ExpiresAt.Value > DateTime.UtcNow.AddMinutes(5))
+ {
+ // Access token is still valid
+ return await _encryptionService.DecryptAsync(credential.EncryptedValue);
+ }
+
+ // Access token expired, refresh it
+ _logger.LogInformation("Access token expired, refreshing...");
+
+ var tokenResponse = await _flow.RefreshTokenAsync(
+ "user",
+ refreshToken,
+ cancellationToken);
+
+ // Update stored tokens
+ await StoreTokensAsync(tokenResponse, cancellationToken);
+
+ _logger.LogInformation("Successfully refreshed access token");
+ return tokenResponse.AccessToken;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting access token");
+ throw;
+ }
+ }
+
+ ///
+ public async Task IsConfiguredAsync()
+ {
+ if (!_config.IsValid())
+ {
+ return false;
+ }
+
+ var credential = await GetStoredCredentialAsync();
+ return credential != null && !string.IsNullOrWhiteSpace(credential.EncryptedRefreshToken);
+ }
+
+ ///
+ public async Task RevokeTokensAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var credential = await GetStoredCredentialAsync(cancellationToken);
+
+ if (credential != null)
+ {
+ // Revoke the token with Google
+ var accessToken = await _encryptionService.DecryptAsync(credential.EncryptedValue);
+ await _flow.RevokeTokenAsync("user", accessToken, cancellationToken);
+
+ // Remove from database
+ _dbContext.PlatformCredentials.Remove(credential);
+ await _dbContext.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation("Successfully revoked YouTube tokens");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error revoking YouTube tokens");
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetChannelInfoAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Fetching YouTube channel information");
+
+ // Get a valid access token
+ var accessToken = await GetAccessTokenAsync(cancellationToken);
+
+ // Create YouTube service
+ var youtubeService = new YouTubeService(new BaseClientService.Initializer
+ {
+ HttpClientInitializer = GoogleCredential.FromAccessToken(accessToken),
+ ApplicationName = "Thrive Stream Controller"
+ });
+
+ // Request channel information for the authenticated user
+ var channelsRequest = youtubeService.Channels.List("snippet,statistics,contentDetails");
+ channelsRequest.Mine = true;
+
+ var channelsResponse = await channelsRequest.ExecuteAsync(cancellationToken);
+
+ if (channelsResponse.Items == null || channelsResponse.Items.Count == 0)
+ {
+ _logger.LogWarning("No YouTube channel found for authenticated user");
+ return null;
+ }
+
+ var channel = channelsResponse.Items[0];
+
+ var channelInfo = new YouTubeChannelInfo
+ {
+ Id = channel.Id,
+ Title = channel.Snippet.Title,
+ Description = channel.Snippet.Description,
+ CustomUrl = channel.Snippet.CustomUrl,
+ ThumbnailUrl = channel.Snippet.Thumbnails?.High?.Url
+ ?? channel.Snippet.Thumbnails?.Medium?.Url
+ ?? channel.Snippet.Thumbnails?.Default__?.Url,
+ SubscriberCount = (long?)channel.Statistics?.SubscriberCount,
+ VideoCount = (long?)channel.Statistics?.VideoCount,
+ ViewCount = (long?)channel.Statistics?.ViewCount,
+ HiddenSubscriberCount = channel.Statistics?.HiddenSubscriberCount ?? false,
+ PublishedAt = channel.Snippet.PublishedAtDateTimeOffset?.DateTime
+ };
+
+ _logger.LogInformation("Successfully fetched YouTube channel info: {ChannelTitle}", channelInfo.Title);
+ return channelInfo;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching YouTube channel information");
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetConnectionTimestampAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var credential = await GetStoredCredentialAsync(cancellationToken);
+ return credential?.CreatedAt;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting YouTube connection timestamp");
+ throw;
+ }
+ }
+
+ private async Task GetStoredCredentialAsync(CancellationToken cancellationToken = default)
+ {
+ return await _dbContext.PlatformCredentials
+ .Where(c => c.Platform == "YouTube" && c.IsActive)
+ .OrderByDescending(c => c.CreatedAt)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ private async Task StoreTokensAsync(TokenResponse tokenResponse, CancellationToken cancellationToken = default)
+ {
+ // Deactivate any existing credentials
+ var existingCredentials = await _dbContext.PlatformCredentials
+ .Where(c => c.Platform == "YouTube" && c.IsActive)
+ .ToListAsync(cancellationToken);
+
+ foreach (var cred in existingCredentials)
+ {
+ cred.IsActive = false;
+ }
+
+ // Encrypt the tokens
+ var encryptedAccessToken = await _encryptionService.EncryptAsync(tokenResponse.AccessToken);
+ var encryptedRefreshToken = tokenResponse.RefreshToken != null
+ ? await _encryptionService.EncryptAsync(tokenResponse.RefreshToken)
+ : null;
+
+ // Create new credential record
+ var credential = new PlatformCredential
+ {
+ Platform = "YouTube",
+ CredentialType = "OAuth2",
+ EncryptedValue = encryptedAccessToken,
+ EncryptedRefreshToken = encryptedRefreshToken,
+ ExpiresAt = tokenResponse.IssuedUtc.AddSeconds(tokenResponse.ExpiresInSeconds ?? 3600),
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ };
+
+ _dbContext.PlatformCredentials.Add(credential);
+ await _dbContext.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ ///
+ /// Null data store for Google OAuth flow (we handle storage ourselves).
+ ///
+ internal class NullDataStore : IDataStore
+ {
+ public Task StoreAsync(string key, T value) => Task.CompletedTask;
+ public Task DeleteAsync(string key) => Task.CompletedTask;
+ public Task GetAsync(string key) => Task.FromResult(default(T)!);
+ public Task ClearAsync() => Task.CompletedTask;
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/Services/YouTubeLiveService.cs b/API/ThriveStreamController.Core/Services/YouTubeLiveService.cs
new file mode 100644
index 0000000..fdbda32
--- /dev/null
+++ b/API/ThriveStreamController.Core/Services/YouTubeLiveService.cs
@@ -0,0 +1,506 @@
+using Google.Apis.Auth.OAuth2;
+using Google.Apis.Services;
+using Google.Apis.Upload;
+using Google.Apis.YouTube.v3;
+using Google.Apis.YouTube.v3.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Data;
+using ThriveStreamController.Data.Entities;
+
+namespace ThriveStreamController.Core.Services
+{
+ ///
+ /// Service for managing YouTube Live broadcasts.
+ ///
+ public class YouTubeLiveService : IYouTubeLiveService
+ {
+ private readonly IYouTubeAuthService _authService;
+ private readonly ApplicationDbContext _dbContext;
+ private readonly ILogger _logger;
+ private const string PersistentStreamTitle = "Thrive Stream Controller - Persistent Stream";
+
+ public YouTubeLiveService(
+ IYouTubeAuthService authService,
+ ApplicationDbContext dbContext,
+ ILogger logger)
+ {
+ _authService = authService;
+ _dbContext = dbContext;
+ _logger = logger;
+ }
+
+ private async Task GetYouTubeServiceAsync(CancellationToken cancellationToken)
+ {
+ var accessToken = await _authService.GetAccessTokenAsync(cancellationToken);
+ return new YouTubeService(new BaseClientService.Initializer
+ {
+ HttpClientInitializer = GoogleCredential.FromAccessToken(accessToken),
+ ApplicationName = "Thrive Stream Controller"
+ });
+ }
+
+ ///
+ public async Task CreateBroadcastAsync(
+ string title,
+ string? description = null,
+ DateTime? scheduledStartTime = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Creating YouTube broadcast: {Title}", title);
+
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var startTime = scheduledStartTime ?? DateTime.UtcNow.AddMinutes(1);
+
+ var broadcast = new LiveBroadcast
+ {
+ Snippet = new LiveBroadcastSnippet
+ {
+ Title = title,
+ Description = description ?? string.Empty,
+ ScheduledStartTimeDateTimeOffset = startTime
+ },
+ Status = new LiveBroadcastStatus
+ {
+ PrivacyStatus = "public",
+ SelfDeclaredMadeForKids = false
+ },
+ ContentDetails = new LiveBroadcastContentDetails
+ {
+ EnableAutoStart = false,
+ EnableAutoStop = true,
+ EnableDvr = true,
+ EnableContentEncryption = true,
+ EnableEmbed = true,
+ RecordFromStart = true,
+ StartWithSlate = false,
+ EnableClosedCaptions = false,
+ ClosedCaptionsType = "closedCaptionsDisabled",
+ MonitorStream = new MonitorStreamInfo
+ {
+ EnableMonitorStream = false,
+ BroadcastStreamDelayMs = 0
+ }
+ }
+ };
+
+ var request = youtubeService.LiveBroadcasts.Insert(broadcast, "snippet,status,contentDetails");
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ _logger.LogInformation("Created YouTube broadcast: {BroadcastId}", response.Id);
+
+ return MapBroadcastToInfo(response);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error creating YouTube broadcast");
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetOrCreatePersistentStreamAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Check if we have a persistent stream config in the database
+ var persistentConfig = await _dbContext.PersistentStreamConfigs
+ .Where(c => c.Platform == "YouTube" && c.IsActive)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (persistentConfig != null && !string.IsNullOrEmpty(persistentConfig.StreamId))
+ {
+ _logger.LogInformation("Found existing persistent stream: {StreamId}", persistentConfig.StreamId);
+
+ // Verify the stream still exists on YouTube
+ var existingStream = await GetStreamByIdAsync(persistentConfig.StreamId, cancellationToken);
+ if (existingStream != null)
+ {
+ return existingStream;
+ }
+
+ _logger.LogWarning("Persistent stream {StreamId} no longer exists, creating new one", persistentConfig.StreamId);
+ }
+
+ // Create a new persistent stream
+ return await CreatePersistentStreamAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting or creating persistent stream");
+ throw;
+ }
+ }
+
+ private async Task CreatePersistentStreamAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Creating new persistent YouTube stream");
+
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+
+ var stream = new LiveStream
+ {
+ Snippet = new LiveStreamSnippet
+ {
+ Title = PersistentStreamTitle,
+ Description = "Persistent stream for Thrive Community Church live broadcasts"
+ },
+ Cdn = new CdnSettings
+ {
+ FrameRate = "60fps",
+ Resolution = "1080p",
+ IngestionType = "rtmp"
+ }
+ };
+
+ var request = youtubeService.LiveStreams.Insert(stream, "snippet,cdn,status");
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ var streamInfo = MapStreamToInfo(response);
+
+ // Store in database for future use
+ await StorePersistentStreamConfigAsync(streamInfo, cancellationToken);
+
+ _logger.LogInformation("Created persistent stream: {StreamId}", streamInfo.Id);
+ return streamInfo;
+ }
+
+ private async Task StorePersistentStreamConfigAsync(YouTubeStreamInfo streamInfo, CancellationToken cancellationToken)
+ {
+ // Deactivate any existing YouTube stream configs
+ var existingConfigs = await _dbContext.PersistentStreamConfigs
+ .Where(c => c.Platform == "YouTube" && c.IsActive)
+ .ToListAsync(cancellationToken);
+
+ foreach (var config in existingConfigs)
+ {
+ config.IsActive = false;
+ }
+
+ // Create new config
+ var newConfig = new PersistentStreamConfig
+ {
+ Platform = "YouTube",
+ StreamId = streamInfo.Id,
+ StreamKey = streamInfo.StreamKey,
+ RtmpUrl = streamInfo.RtmpUrl,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ };
+
+ _dbContext.PersistentStreamConfigs.Add(newConfig);
+ await _dbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private async Task GetStreamByIdAsync(string streamId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var request = youtubeService.LiveStreams.List("snippet,cdn,status");
+ request.Id = streamId;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Items == null || response.Items.Count == 0)
+ {
+ return null;
+ }
+
+ return MapStreamToInfo(response.Items[0]);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error fetching stream {StreamId}", streamId);
+ return null;
+ }
+ }
+
+ ///
+ public async Task BindBroadcastToStreamAsync(string broadcastId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Binding broadcast {BroadcastId} to persistent stream", broadcastId);
+
+ var streamInfo = await GetOrCreatePersistentStreamAsync(cancellationToken);
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+
+ var request = youtubeService.LiveBroadcasts.Bind(broadcastId, "id,snippet,contentDetails,status");
+ request.StreamId = streamInfo.Id;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ _logger.LogInformation("Successfully bound broadcast {BroadcastId} to stream {StreamId}",
+ broadcastId, streamInfo.Id);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error binding broadcast {BroadcastId} to stream", broadcastId);
+ return false;
+ }
+ }
+
+ ///
+ public async Task TransitionToTestingAsync(string broadcastId, CancellationToken cancellationToken = default)
+ {
+ return await TransitionBroadcastAsync(broadcastId, LiveBroadcastsResource.TransitionRequest.BroadcastStatusEnum.Testing, cancellationToken);
+ }
+
+ ///
+ public async Task TransitionToLiveAsync(string broadcastId, CancellationToken cancellationToken = default)
+ {
+ return await TransitionBroadcastAsync(broadcastId, LiveBroadcastsResource.TransitionRequest.BroadcastStatusEnum.Live, cancellationToken);
+ }
+
+ ///
+ public async Task EndBroadcastAsync(string broadcastId, CancellationToken cancellationToken = default)
+ {
+ return await TransitionBroadcastAsync(broadcastId, LiveBroadcastsResource.TransitionRequest.BroadcastStatusEnum.Complete, cancellationToken);
+ }
+
+ private async Task TransitionBroadcastAsync(
+ string broadcastId,
+ LiveBroadcastsResource.TransitionRequest.BroadcastStatusEnum status,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogInformation("Transitioning broadcast {BroadcastId} to {Status}", broadcastId, status);
+
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var request = youtubeService.LiveBroadcasts.Transition(status, broadcastId, "id,snippet,contentDetails,status");
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ _logger.LogInformation("Broadcast {BroadcastId} transitioned to {Status}", broadcastId, status);
+ return MapBroadcastToInfo(response);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error transitioning broadcast {BroadcastId} to {Status}", broadcastId, status);
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetBroadcastStatusAsync(string broadcastId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var request = youtubeService.LiveBroadcasts.List("id,snippet,contentDetails,status");
+ request.Id = broadcastId;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Items == null || response.Items.Count == 0)
+ {
+ throw new InvalidOperationException($"Broadcast {broadcastId} not found");
+ }
+
+ return MapBroadcastToInfo(response.Items[0]);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting broadcast status for {BroadcastId}", broadcastId);
+ throw;
+ }
+ }
+
+ ///
+ public async Task SetThumbnailAsync(string broadcastId, string thumbnailPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(thumbnailPath))
+ {
+ _logger.LogError("Thumbnail file not found: {Path}", thumbnailPath);
+ return false;
+ }
+
+ _logger.LogInformation("Setting thumbnail for broadcast {BroadcastId}", broadcastId);
+
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+
+ using var stream = new FileStream(thumbnailPath, FileMode.Open, FileAccess.Read);
+ var contentType = GetContentType(thumbnailPath);
+
+ var request = youtubeService.Thumbnails.Set(broadcastId, stream, contentType);
+ var response = await request.UploadAsync(cancellationToken);
+
+ if (response.Status == UploadStatus.Completed)
+ {
+ _logger.LogInformation("Thumbnail set successfully for broadcast {BroadcastId}", broadcastId);
+ return true;
+ }
+
+ _logger.LogError("Failed to upload thumbnail: {Status}", response.Status);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error setting thumbnail for broadcast {BroadcastId}", broadcastId);
+ return false;
+ }
+ }
+
+ private static string GetContentType(string filePath)
+ {
+ var extension = Path.GetExtension(filePath).ToLowerInvariant();
+ return extension switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ _ => "application/octet-stream"
+ };
+ }
+
+ ///
+ public async Task UpdateBroadcastAsync(
+ string broadcastId,
+ string? title = null,
+ string? description = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _logger.LogInformation("Updating broadcast {BroadcastId}", broadcastId);
+
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+
+ // Get current broadcast
+ var getRequest = youtubeService.LiveBroadcasts.List("id,snippet,status");
+ getRequest.Id = broadcastId;
+ var current = await getRequest.ExecuteAsync(cancellationToken);
+
+ if (current.Items == null || current.Items.Count == 0)
+ {
+ throw new InvalidOperationException($"Broadcast {broadcastId} not found");
+ }
+
+ var broadcast = current.Items[0];
+
+ // Update fields
+ if (!string.IsNullOrEmpty(title))
+ {
+ broadcast.Snippet.Title = title;
+ }
+ if (description != null)
+ {
+ broadcast.Snippet.Description = description;
+ }
+
+ var updateRequest = youtubeService.LiveBroadcasts.Update(broadcast, "snippet");
+ var response = await updateRequest.ExecuteAsync(cancellationToken);
+
+ _logger.LogInformation("Updated broadcast {BroadcastId}", broadcastId);
+ return MapBroadcastToInfo(response);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating broadcast {BroadcastId}", broadcastId);
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetActiveBroadcastAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var request = youtubeService.LiveBroadcasts.List("id,snippet,contentDetails,status");
+ request.BroadcastStatus = LiveBroadcastsResource.ListRequest.BroadcastStatusEnum.Active;
+ request.MaxResults = 1;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Items == null || response.Items.Count == 0)
+ {
+ return null;
+ }
+
+ return MapBroadcastToInfo(response.Items[0]);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting active broadcast");
+ throw;
+ }
+ }
+
+ ///
+ public async Task GetBroadcastDefaultsAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var youtubeService = await GetYouTubeServiceAsync(cancellationToken);
+ var request = youtubeService.LiveBroadcasts.List("id,snippet,status");
+ request.BroadcastStatus = LiveBroadcastsResource.ListRequest.BroadcastStatusEnum.Completed;
+ request.MaxResults = 1;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Items == null || response.Items.Count == 0)
+ {
+ return null;
+ }
+
+ var lastBroadcast = response.Items[0];
+ return new YouTubeBroadcastDefaults
+ {
+ TitleTemplate = lastBroadcast.Snippet.Title,
+ Description = lastBroadcast.Snippet.Description,
+ PrivacyStatus = lastBroadcast.Status?.PrivacyStatus ?? "public",
+ SourceBroadcastId = lastBroadcast.Id
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting broadcast defaults");
+ return null;
+ }
+ }
+
+ private static YouTubeBroadcastInfo MapBroadcastToInfo(LiveBroadcast broadcast)
+ {
+ return new YouTubeBroadcastInfo
+ {
+ Id = broadcast.Id,
+ Title = broadcast.Snippet?.Title ?? string.Empty,
+ Description = broadcast.Snippet?.Description,
+ ScheduledStartTime = broadcast.Snippet?.ScheduledStartTimeDateTimeOffset?.DateTime,
+ ActualStartTime = broadcast.Snippet?.ActualStartTimeDateTimeOffset?.DateTime,
+ ActualEndTime = broadcast.Snippet?.ActualEndTimeDateTimeOffset?.DateTime,
+ LifecycleStatus = broadcast.Status?.LifeCycleStatus ?? "unknown",
+ PrivacyStatus = broadcast.Status?.PrivacyStatus ?? "private",
+ BoundStreamId = broadcast.ContentDetails?.BoundStreamId,
+ WatchUrl = $"https://www.youtube.com/watch?v={broadcast.Id}",
+ ThumbnailUrl = broadcast.Snippet?.Thumbnails?.High?.Url
+ ?? broadcast.Snippet?.Thumbnails?.Medium?.Url
+ ?? broadcast.Snippet?.Thumbnails?.Default__?.Url
+ };
+ }
+
+ private static YouTubeStreamInfo MapStreamToInfo(LiveStream stream)
+ {
+ return new YouTubeStreamInfo
+ {
+ Id = stream.Id,
+ Title = stream.Snippet?.Title ?? string.Empty,
+ StreamKey = stream.Cdn?.IngestionInfo?.StreamName ?? string.Empty,
+ RtmpUrl = stream.Cdn?.IngestionInfo?.IngestionAddress ?? string.Empty,
+ HealthStatus = stream.Status?.HealthStatus?.Status,
+ IsActive = stream.Status?.StreamStatus == "active"
+ };
+ }
+ }
+}
diff --git a/API/ThriveStreamController.Core/System/SystemMessages.cs b/API/ThriveStreamController.Core/System/SystemMessages.cs
new file mode 100644
index 0000000..99a2dd9
--- /dev/null
+++ b/API/ThriveStreamController.Core/System/SystemMessages.cs
@@ -0,0 +1,32 @@
+namespace ThriveStreamController.Core.System
+{
+ ///
+ /// Contains standard system messages for validation and error handling.
+ ///
+ public static class SystemMessages
+ {
+ // General validation messages
+ public const string EmptyRequest = "Request cannot be null or empty.";
+ public const string NullProperty = "Property '{0}' cannot be null or empty.";
+ public const string InvalidProperty = "Property '{0}' has an invalid value: {1}";
+ public const string Success = "Operation completed successfully.";
+
+ // OBS-specific messages
+ public const string OBSConnectionSuccess = "Successfully connected to OBS.";
+ public const string OBSConnectionFailed = "Failed to connect to OBS. Please verify the URL and password are correct, and that OBS is running with WebSocket server enabled.";
+ public const string OBSNotConnected = "Not connected to OBS. Please connect first.";
+ public const string OBSDisconnectSuccess = "Successfully disconnected from OBS.";
+ public const string OBSInvalidUrl = "Invalid WebSocket URL. URL must start with 'ws://' or 'wss://'.";
+ public const string OBSUrlRequired = "WebSocket URL is required.";
+ public const string OBSSceneNameRequired = "Scene name is required.";
+ public const string OBSSceneSwitchSuccess = "Successfully switched to scene '{0}'.";
+ public const string OBSSceneSwitchFailed = "Failed to switch to scene '{0}'.";
+ public const string OBSStreamStartSuccess = "Stream started successfully.";
+ public const string OBSStreamStartFailed = "Failed to start stream.";
+ public const string OBSStreamStopSuccess = "Stream stopped successfully.";
+ public const string OBSStreamStopFailed = "Failed to stop stream.";
+ public const string OBSConnectionTimeout = "Connection to OBS timed out. Please ensure OBS is running and the WebSocket server is enabled.";
+ public const string OBSAuthenticationFailed = "Authentication failed. Please check your password.";
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/System/SystemResponseBase.cs b/API/ThriveStreamController.Core/System/SystemResponseBase.cs
new file mode 100644
index 0000000..18f77d3
--- /dev/null
+++ b/API/ThriveStreamController.Core/System/SystemResponseBase.cs
@@ -0,0 +1,24 @@
+namespace ThriveStreamController.Core.System
+{
+ ///
+ /// Base class for all system responses.
+ ///
+ public class SystemResponseBase
+ {
+ ///
+ /// Gets or sets a value indicating whether the response has errors.
+ ///
+ public bool HasErrors { get; set; }
+
+ ///
+ /// Gets or sets the error message if HasErrors is true.
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// Gets or sets the success message if HasErrors is false.
+ ///
+ public string? SuccessMessage { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/System/ValidationResponse.cs b/API/ThriveStreamController.Core/System/ValidationResponse.cs
new file mode 100644
index 0000000..bb20b1b
--- /dev/null
+++ b/API/ThriveStreamController.Core/System/ValidationResponse.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Logging;
+
+namespace ThriveStreamController.Core.System
+{
+ ///
+ /// Generic validation response used to validate request objects.
+ ///
+ public class ValidationResponse : SystemResponseBase
+ {
+ ///
+ /// Initializes a new instance of the class for failure scenarios.
+ ///
+ /// Indicates whether an error occurred.
+ /// The error message.
+ public ValidationResponse(bool didError, string errorMsg)
+ {
+ HasErrors = didError;
+ ErrorMessage = errorMsg;
+ }
+
+ ///
+ /// Initializes a new instance of the class for success scenarios.
+ ///
+ /// The success message.
+ public ValidationResponse(string successMsg)
+ {
+ HasErrors = false;
+ SuccessMessage = successMsg;
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj b/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj
new file mode 100644
index 0000000..1ede096
--- /dev/null
+++ b/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/API/ThriveStreamController.Data/ApplicationDbContext.cs b/API/ThriveStreamController.Data/ApplicationDbContext.cs
new file mode 100644
index 0000000..058fee3
--- /dev/null
+++ b/API/ThriveStreamController.Data/ApplicationDbContext.cs
@@ -0,0 +1,101 @@
+using Microsoft.EntityFrameworkCore;
+using ThriveStreamController.Data.Entities;
+
+namespace ThriveStreamController.Data
+{
+ ///
+ /// Database context for the Thrive Stream Controller application.
+ /// Manages database connections and entity configurations.
+ ///
+ public class ApplicationDbContext : DbContext
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The options to be used by the DbContext.
+ public ApplicationDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ ///
+ /// Gets or sets the DbSet for StreamSession entities.
+ ///
+ public DbSet StreamSessions { get; set; }
+
+ ///
+ /// Gets or sets the DbSet for PlatformCredential entities.
+ ///
+ public DbSet PlatformCredentials { get; set; }
+
+ ///
+ /// Gets or sets the DbSet for PersistentStreamConfig entities.
+ ///
+ public DbSet PersistentStreamConfigs { get; set; }
+
+ ///
+ /// Configures the entity models and their relationships.
+ ///
+ /// The builder being used to construct the model for this context.
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // Configure StreamSession entity
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Status).IsRequired().HasMaxLength(50);
+ entity.Property(e => e.SceneName).HasMaxLength(200);
+ entity.Property(e => e.Notes).HasMaxLength(1000);
+ entity.Property(e => e.StartTime).IsRequired();
+ entity.Property(e => e.CreatedAt).IsRequired();
+ entity.Property(e => e.UpdatedAt).IsRequired();
+ });
+ }
+
+ ///
+ /// Saves all changes made in this context to the database.
+ /// Automatically updates CreatedAt and UpdatedAt timestamps.
+ ///
+ /// The number of state entries written to the database.
+ public override int SaveChanges()
+ {
+ UpdateTimestamps();
+ return base.SaveChanges();
+ }
+
+ ///
+ /// Asynchronously saves all changes made in this context to the database.
+ /// Automatically updates CreatedAt and UpdatedAt timestamps.
+ ///
+ /// A token to observe while waiting for the task to complete.
+ /// A task that represents the asynchronous save operation. The task result contains the number of state entries written to the database.
+ public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ UpdateTimestamps();
+ return base.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ /// Updates the CreatedAt and UpdatedAt timestamps for entities being added or modified.
+ ///
+ private void UpdateTimestamps()
+ {
+ var entries = ChangeTracker.Entries()
+ .Where(e => e.Entity is StreamSession && (e.State == EntityState.Added || e.State == EntityState.Modified));
+
+ foreach (var entry in entries)
+ {
+ var entity = (StreamSession)entry.Entity;
+ entity.UpdatedAt = DateTime.UtcNow;
+
+ if (entry.State == EntityState.Added)
+ {
+ entity.CreatedAt = DateTime.UtcNow;
+ }
+ }
+ }
+ }
+}
+
diff --git a/API/ThriveStreamController.Data/Entities/PersistentStreamConfig.cs b/API/ThriveStreamController.Data/Entities/PersistentStreamConfig.cs
new file mode 100644
index 0000000..a6f6110
--- /dev/null
+++ b/API/ThriveStreamController.Data/Entities/PersistentStreamConfig.cs
@@ -0,0 +1,64 @@
+namespace ThriveStreamController.Data.Entities
+{
+ ///
+ /// Represents persistent stream configuration for a platform (broadcast IDs, stream keys, RTMP URLs).
+ /// These are reused across multiple streaming sessions.
+ ///
+ public class PersistentStreamConfig
+ {
+ ///
+ /// Gets or sets the unique identifier for the stream configuration.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the platform name (YouTube, Facebook).
+ ///
+ public string Platform { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the broadcast/video ID from the platform.
+ /// For YouTube: liveBroadcast ID
+ /// For Facebook: live_video ID
+ ///
+ public string? BroadcastId { get; set; }
+
+ ///
+ /// Gets or sets the stream ID (YouTube only).
+ /// For YouTube: liveStream ID
+ ///
+ public string? StreamId { get; set; }
+
+ ///
+ /// Gets or sets the persistent stream key.
+ ///
+ public string? StreamKey { get; set; }
+
+ ///
+ /// Gets or sets the RTMP URL for streaming.
+ ///
+ public string? RtmpUrl { get; set; }
+
+ ///
+ /// Gets or sets whether this configuration is currently active.
+ /// Only one configuration per platform should be active at a time.
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// Gets or sets additional configuration data as JSON.
+ ///
+ public string? ConfigurationJson { get; set; }
+
+ ///
+ /// Gets or sets the date and time when this record was created.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the date and time when this record was last updated.
+ ///
+ public DateTime UpdatedAt { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Data/Entities/PlatformCredential.cs b/API/ThriveStreamController.Data/Entities/PlatformCredential.cs
new file mode 100644
index 0000000..1956eda
--- /dev/null
+++ b/API/ThriveStreamController.Data/Entities/PlatformCredential.cs
@@ -0,0 +1,54 @@
+namespace ThriveStreamController.Data.Entities
+{
+ ///
+ /// Represents encrypted credentials for streaming platforms (YouTube, Facebook).
+ ///
+ public class PlatformCredential
+ {
+ ///
+ /// Gets or sets the unique identifier for the credential.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the platform name (YouTube, Facebook).
+ ///
+ public string Platform { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the credential type (OAuth, AccessToken, ApiKey).
+ ///
+ public string CredentialType { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the encrypted credential value.
+ ///
+ public string EncryptedValue { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the encrypted refresh token (for OAuth).
+ ///
+ public string? EncryptedRefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the expiration date/time for the credential.
+ ///
+ public DateTime? ExpiresAt { get; set; }
+
+ ///
+ /// Gets or sets whether this credential is currently active.
+ ///
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// Gets or sets the date and time when this record was created.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the date and time when this record was last updated.
+ ///
+ public DateTime UpdatedAt { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Data/Entities/StreamSession.cs b/API/ThriveStreamController.Data/Entities/StreamSession.cs
new file mode 100644
index 0000000..1d6f6f2
--- /dev/null
+++ b/API/ThriveStreamController.Data/Entities/StreamSession.cs
@@ -0,0 +1,52 @@
+using System;
+
+namespace ThriveStreamController.Data.Entities
+{
+ ///
+ /// Represents a streaming session with start/end times and status information.
+ ///
+ public class StreamSession
+ {
+ ///
+ /// Gets or sets the unique identifier for the stream session.
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Gets or sets the date and time when the stream session started.
+ ///
+ public DateTime StartTime { get; set; }
+
+ ///
+ /// Gets or sets the date and time when the stream session ended.
+ /// Can be null if the stream is still active.
+ ///
+ public DateTime? EndTime { get; set; }
+
+ ///
+ /// Gets or sets the current status of the stream session.
+ ///
+ public string Status { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the name of the scene that was active when the stream started.
+ ///
+ public string? SceneName { get; set; }
+
+ ///
+ /// Gets or sets additional notes or metadata about the stream session.
+ ///
+ public string? Notes { get; set; }
+
+ ///
+ /// Gets or sets the date and time when this record was created.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the date and time when this record was last updated.
+ ///
+ public DateTime UpdatedAt { get; set; }
+ }
+}
+
diff --git a/API/ThriveStreamController.Data/ThriveStreamController.Data.csproj b/API/ThriveStreamController.Data/ThriveStreamController.Data.csproj
new file mode 100644
index 0000000..5dff98b
--- /dev/null
+++ b/API/ThriveStreamController.Data/ThriveStreamController.Data.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/API/ThriveStreamController.Tests/Controllers/OBSControllerTests.cs b/API/ThriveStreamController.Tests/Controllers/OBSControllerTests.cs
new file mode 100644
index 0000000..4873034
--- /dev/null
+++ b/API/ThriveStreamController.Tests/Controllers/OBSControllerTests.cs
@@ -0,0 +1,219 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using ThriveStreamController.API.Controllers;
+using ThriveStreamController.Core.Interfaces;
+using ThriveStreamController.Core.Models;
+using ThriveStreamController.Core.Models.Requests;
+
+namespace ThriveStreamController.Tests.Controllers
+{
+ [TestClass]
+ public class OBSControllerTests
+ {
+ private Mock _mockOBSService = null!;
+ private Mock> _mockLogger = null!;
+ private Mock _mockConfiguration = null!;
+ private OBSController _controller = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _mockOBSService = new Mock();
+ _mockLogger = new Mock>();
+ _mockConfiguration = new Mock();
+
+ _controller = new OBSController(
+ _mockOBSService.Object,
+ _mockLogger.Object,
+ _mockConfiguration.Object);
+ }
+
+ [TestMethod]
+ public void GetStatus_ReturnsOkWithConnectionStatus()
+ {
+ // Arrange
+ var expectedStatus = new OBSConnectionStatus
+ {
+ IsConnected = true,
+ ServerUrl = "ws://localhost:4455",
+ ConnectedAt = DateTime.UtcNow
+ };
+ _mockOBSService.Setup(s => s.ConnectionStatus).Returns(expectedStatus);
+
+ // Act
+ var result = _controller.GetStatus();
+
+ // Assert
+ var okResult = result.Result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ Assert.AreEqual(expectedStatus, okResult.Value);
+ }
+
+ [TestMethod]
+ public async Task Connect_WhenSuccessful_ReturnsOkWithStatus()
+ {
+ // Arrange
+ var expectedStatus = new OBSConnectionStatus
+ {
+ IsConnected = true,
+ ServerUrl = "ws://localhost:4455"
+ };
+
+ _mockConfiguration.Setup(c => c["OBS:WebSocketUrl"]).Returns("ws://localhost:4455");
+ _mockConfiguration.Setup(c => c["OBS:Password"]).Returns("testpassword");
+ _mockOBSService.Setup(s => s.ConnectAsync("ws://localhost:4455", "testpassword"))
+ .ReturnsAsync(true);
+ _mockOBSService.Setup(s => s.ConnectionStatus).Returns(expectedStatus);
+
+ // Act
+ var result = await _controller.Connect();
+
+ // Assert
+ var okResult = result.Result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task Connect_WhenFailed_ReturnsBadRequest()
+ {
+ // Arrange
+ _mockConfiguration.Setup(c => c["OBS:WebSocketUrl"]).Returns("ws://localhost:4455");
+ _mockConfiguration.Setup(c => c["OBS:Password"]).Returns((string?)null);
+ _mockOBSService.Setup(s => s.ConnectAsync("ws://localhost:4455", null))
+ .ReturnsAsync(false);
+
+ // Act
+ var result = await _controller.Connect();
+
+ // Assert
+ var badRequestResult = result.Result as BadRequestObjectResult;
+ Assert.IsNotNull(badRequestResult);
+ Assert.AreEqual(400, badRequestResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task Disconnect_ReturnsOkMessage()
+ {
+ // Arrange
+ _mockOBSService.Setup(s => s.DisconnectAsync()).Returns(Task.CompletedTask);
+
+ // Act
+ var result = await _controller.Disconnect();
+
+ // Assert
+ var okResult = result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task GetScenes_ReturnsListOfScenes()
+ {
+ // Arrange
+ var scenes = new List
+ {
+ new() { Name = "Scene 1", IsActive = true, Index = 0 },
+ new() { Name = "Scene 2", IsActive = false, Index = 1 }
+ };
+ _mockOBSService.Setup(s => s.GetScenesAsync()).ReturnsAsync(scenes);
+
+ // Act
+ var result = await _controller.GetScenes();
+
+ // Assert
+ var okResult = result.Result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ var response = okResult.Value as ScenesResponse;
+ Assert.IsNotNull(response);
+ Assert.AreEqual(2, response.Scenes.Count);
+ Assert.AreEqual("Scene 1", response.CurrentScene);
+ }
+
+ [TestMethod]
+ public async Task SwitchScene_WhenSuccessful_ReturnsOk()
+ {
+ // Arrange
+ var request = new SwitchSceneRequest { SceneName = "Scene 2" };
+ _mockOBSService.Setup(s => s.SwitchSceneAsync("Scene 2")).ReturnsAsync(true);
+
+ // Act
+ var result = await _controller.SwitchScene(request);
+
+ // Assert
+ var okResult = result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task SwitchScene_WhenFailed_ReturnsBadRequest()
+ {
+ // Arrange
+ var request = new SwitchSceneRequest { SceneName = "NonExistent" };
+ _mockOBSService.Setup(s => s.SwitchSceneAsync("NonExistent")).ReturnsAsync(false);
+
+ // Act
+ var result = await _controller.SwitchScene(request);
+
+ // Assert
+ var badRequestResult = result as BadRequestObjectResult;
+ Assert.IsNotNull(badRequestResult);
+ Assert.AreEqual(400, badRequestResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task GetStreamingStatus_ReturnsCurrentStatus()
+ {
+ // Arrange
+ var expectedStatus = new StreamingStatus
+ {
+ IsStreaming = true,
+ StreamDurationSeconds = 300
+ };
+ _mockOBSService.Setup(s => s.GetStreamingStatusAsync()).ReturnsAsync(expectedStatus);
+
+ // Act
+ var result = await _controller.GetStreamingStatus();
+
+ // Assert
+ var okResult = result.Result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(expectedStatus, okResult.Value);
+ }
+
+ [TestMethod]
+ public async Task StartStreaming_WhenSuccessful_ReturnsOk()
+ {
+ // Arrange
+ _mockOBSService.Setup(s => s.StartStreamingAsync()).ReturnsAsync(true);
+
+ // Act
+ var result = await _controller.StartStreaming();
+
+ // Assert
+ var okResult = result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ }
+
+ [TestMethod]
+ public async Task StopStreaming_WhenSuccessful_ReturnsOk()
+ {
+ // Arrange
+ _mockOBSService.Setup(s => s.StopStreamingAsync()).ReturnsAsync(true);
+
+ // Act
+ var result = await _controller.StopStreaming();
+
+ // Assert
+ var okResult = result as OkObjectResult;
+ Assert.IsNotNull(okResult);
+ Assert.AreEqual(200, okResult.StatusCode);
+ }
+ }
+}
diff --git a/API/ThriveStreamController.Tests/Services/CredentialEncryptionServiceTests.cs b/API/ThriveStreamController.Tests/Services/CredentialEncryptionServiceTests.cs
new file mode 100644
index 0000000..9959ee9
--- /dev/null
+++ b/API/ThriveStreamController.Tests/Services/CredentialEncryptionServiceTests.cs
@@ -0,0 +1,139 @@
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using ThriveStreamController.Core.Services;
+
+namespace ThriveStreamController.Tests.Services
+{
+ [TestClass]
+ public class CredentialEncryptionServiceTests
+ {
+ private Mock