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 _mockDataProtectionProvider = null!; + private Mock _mockDataProtector = null!; + private CredentialEncryptionService _service = null!; + + [TestInitialize] + public void Setup() + { + _mockDataProtectionProvider = new Mock(); + _mockDataProtector = new Mock(); + + _mockDataProtectionProvider + .Setup(p => p.CreateProtector(It.IsAny())) + .Returns(_mockDataProtector.Object); + + _service = new CredentialEncryptionService(_mockDataProtectionProvider.Object); + } + + [TestMethod] + public async Task EncryptAsync_WithValidPlainText_ReturnsBase64String() + { + // Arrange + var plainText = "my-secret-token"; + var expectedBytes = System.Text.Encoding.UTF8.GetBytes(plainText); + var protectedBytes = new byte[] { 1, 2, 3, 4, 5 }; + + _mockDataProtector + .Setup(p => p.Protect(It.IsAny())) + .Returns(protectedBytes); + + // Act + var result = await _service.EncryptAsync(plainText); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(Convert.ToBase64String(protectedBytes), result); + _mockDataProtector.Verify(p => p.Protect(It.IsAny()), Times.Once); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task EncryptAsync_WithNullPlainText_ThrowsArgumentException() + { + // Act + await _service.EncryptAsync(null!); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task EncryptAsync_WithEmptyPlainText_ThrowsArgumentException() + { + // Act + await _service.EncryptAsync(string.Empty); + } + + [TestMethod] + public async Task DecryptAsync_WithValidEncryptedText_ReturnsPlainText() + { + // Arrange + var expectedPlainText = "my-secret-token"; + var protectedBytes = new byte[] { 1, 2, 3, 4, 5 }; + var encryptedText = Convert.ToBase64String(protectedBytes); + var plainBytes = System.Text.Encoding.UTF8.GetBytes(expectedPlainText); + + _mockDataProtector + .Setup(p => p.Unprotect(It.IsAny())) + .Returns(plainBytes); + + // Act + var result = await _service.DecryptAsync(encryptedText); + + // Assert + Assert.AreEqual(expectedPlainText, result); + _mockDataProtector.Verify(p => p.Unprotect(It.IsAny()), Times.Once); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task DecryptAsync_WithNullEncryptedText_ThrowsArgumentException() + { + // Act + await _service.DecryptAsync(null!); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public async Task DecryptAsync_WithEmptyEncryptedText_ThrowsArgumentException() + { + // Act + await _service.DecryptAsync(string.Empty); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public async Task DecryptAsync_WithInvalidBase64_ThrowsInvalidOperationException() + { + // Act - "not-valid-base64!!!" is not valid base64 + await _service.DecryptAsync("not-valid-base64!!!"); + } + + [TestMethod] + public async Task EncryptAndDecrypt_RoundTrip_Works() + { + // This test verifies the flow works with a real DataProtector + var provider = DataProtectionProvider.Create("TestApp"); + var realService = new CredentialEncryptionService(provider); + + // Arrange + var originalText = "my-super-secret-refresh-token-12345"; + + // Act + var encrypted = await realService.EncryptAsync(originalText); + var decrypted = await realService.DecryptAsync(encrypted); + + // Assert + Assert.AreEqual(originalText, decrypted); + Assert.AreNotEqual(originalText, encrypted); + } + + [TestMethod] + public void Constructor_CreatesProtectorWithCorrectPurpose() + { + // Verify the protector was created with the expected purpose string + _mockDataProtectionProvider.Verify( + p => p.CreateProtector("ThriveStreamController.Credentials.v1"), + Times.Once); + } + } +} + diff --git a/API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj b/API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj new file mode 100644 index 0000000..d9a8e71 --- /dev/null +++ b/API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/API/ThriveStreamController.sln b/API/ThriveStreamController.sln new file mode 100644 index 0000000..c2688a2 --- /dev/null +++ b/API/ThriveStreamController.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThriveStreamController.API", "ThriveStreamController.API\ThriveStreamController.API.csproj", "{07BCA6E9-147F-4FB9-9429-14B3849B6547}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThriveStreamController.Core", "ThriveStreamController.Core\ThriveStreamController.Core.csproj", "{0884A441-870F-44A2-B1E4-02CC04012B07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThriveStreamController.Data", "ThriveStreamController.Data\ThriveStreamController.Data.csproj", "{1FECE607-38BB-496E-B85A-ABBBC1352C1A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThriveStreamController.Tests", "ThriveStreamController.Tests\ThriveStreamController.Tests.csproj", "{5D24193E-F996-4DC1-ABE5-182A05D3E71C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|x64.ActiveCfg = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|x64.Build.0 = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|x86.ActiveCfg = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Debug|x86.Build.0 = Debug|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|Any CPU.Build.0 = Release|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|x64.ActiveCfg = Release|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|x64.Build.0 = Release|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|x86.ActiveCfg = Release|Any CPU + {07BCA6E9-147F-4FB9-9429-14B3849B6547}.Release|x86.Build.0 = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|x64.ActiveCfg = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|x64.Build.0 = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|x86.ActiveCfg = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Debug|x86.Build.0 = Debug|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|Any CPU.Build.0 = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|x64.ActiveCfg = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|x64.Build.0 = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|x86.ActiveCfg = Release|Any CPU + {0884A441-870F-44A2-B1E4-02CC04012B07}.Release|x86.Build.0 = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|x64.Build.0 = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Debug|x86.Build.0 = Debug|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|Any CPU.Build.0 = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|x64.ActiveCfg = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|x64.Build.0 = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|x86.ActiveCfg = Release|Any CPU + {1FECE607-38BB-496E-B85A-ABBBC1352C1A}.Release|x86.Build.0 = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|x64.Build.0 = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Debug|x86.Build.0 = Debug|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|Any CPU.Build.0 = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|x64.ActiveCfg = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|x64.Build.0 = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|x86.ActiveCfg = Release|Any CPU + {5D24193E-F996-4DC1-ABE5-182A05D3E71C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f2cf2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,87 @@ +# ============================================================================= +# Thrive Stream Controller - Multi-stage Docker Build +# Builds both React UI and .NET API, serves UI as static files from API +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Build React UI +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS ui-build + +WORKDIR /app/ui + +# Copy package files first for better caching +COPY UI/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy UI source +COPY UI/ ./ + +# Build production bundle +RUN npm run build + +# ----------------------------------------------------------------------------- +# Stage 2: Build .NET API +# ----------------------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api-build + +WORKDIR /app + +# Copy solution and project files first for better caching +COPY API/ThriveStreamController.sln ./ +COPY API/ThriveStreamController.API/ThriveStreamController.API.csproj ./ThriveStreamController.API/ +COPY API/ThriveStreamController.Core/ThriveStreamController.Core.csproj ./ThriveStreamController.Core/ +COPY API/ThriveStreamController.Data/ThriveStreamController.Data.csproj ./ThriveStreamController.Data/ +COPY API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj ./ThriveStreamController.Tests/ + +# Restore dependencies +RUN dotnet restore + +# Copy all API source +COPY API/ ./ + +# Build and publish +RUN dotnet publish ThriveStreamController.API/ThriveStreamController.API.csproj \ + -c Release \ + -o /app/publish \ + --no-restore + +# ----------------------------------------------------------------------------- +# Stage 3: Final Runtime Image +# ----------------------------------------------------------------------------- +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime + +WORKDIR /app + +# Create non-root user for security +RUN adduser --disabled-password --gecos "" appuser + +# Copy published API +COPY --from=api-build /app/publish ./ + +# Copy built UI to wwwroot +COPY --from=ui-build /app/ui/dist ./wwwroot + +# Create data directory for SQLite database and logs +RUN mkdir -p /app/data /app/logs && chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Environment variables +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ConnectionStrings__DefaultConnection="Data Source=/app/data/thrivestream.db" + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/api/obs/status || exit 1 + +# Start the application +ENTRYPOINT ["dotnet", "ThriveStreamController.API.dll"] + diff --git a/README.md b/README.md index 365ed9f..2b34881 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,314 @@ -# Thrive_Stream_Controller -A volunteer-friendly livestream management application +# Thrive Stream Controller + +A volunteer-friendly livestream management application for churches and organizations that simplifies multi-platform streaming to YouTube and Facebook. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![.NET](https://img.shields.io/badge/.NET-9.0-purple.svg) +![React](https://img.shields.io/badge/React-19-blue.svg) + +## What Is This? + +Thrive Stream Controller is an all-in-one control panel that makes livestreaming simple for volunteers who aren't technical experts. Instead of juggling multiple browser tabs, OBS settings, and platform-specific dashboards, volunteers get **three big buttons**: + +1. **Start Stream** – Creates the YouTube broadcast, starts OBS streaming, and goes live +2. **Open Facebook** – Opens Facebook Live Producer for the one manual "Go Live" click +3. **End Stream** – Stops everything cleanly + +The goal: **Sundays should be stress-free.** Volunteers follow a simple checklist without touching stream keys, RTMP URLs, or API credentials. + +## Features + +- 🎬 **OBS Integration** – Full control via WebSocket (start/stop streaming, switch scenes, monitor audio) +- 📺 **YouTube Live API** – Automated broadcast creation with persistent stream keys +- 📘 **Facebook Live** – Streamlined workflow with persistent keys (one manual click required) +- 🎛️ **Real-time Dashboard** – Live audio meters, scene switching, connection status +- 🔒 **Secure Credential Storage** – OAuth tokens encrypted in local database +- 🐳 **Docker Ready** – Single container deployment for production use + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Streaming PC │ +│ ┌─────────────┐ ┌─────────────────────────────────┐ │ +│ │ │ │ Thrive Stream Controller │ │ +│ │ OBS │◄────┤ │ │ +│ │ Studio │ │ ┌─────────┐ ┌───────────┐ │ │ +│ │ │ │ │ React │ │ .NET 9 │ │ │ +│ └──────┬──────┘ │ │ UI │◄──►│ API │ │ │ +│ │ │ └─────────┘ └─────┬─────┘ │ │ +│ │ └───────────────────────┼────────┘ │ +│ │ RTMP │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Castr │ │ SQLite DB │ │ +│ │ (or OBS │ │ (encrypted │ │ +│ │ direct) │ │ tokens) │ │ +│ └──────┬──────┘ └─────────────┘ │ +└─────────┼──────────────────────────────────────────────────┘ + │ RTMP Distribution + ▼ + ┌─────────────┐ ┌─────────────┐ + │ YouTube │ │ Facebook │ + │ Live │ │ Live │ + └─────────────┘ └─────────────┘ +``` + +**Key Points:** +- OBS handles the actual video encoding and RTMP streaming +- The controller manages OBS via WebSocket and creates/controls platform broadcasts via APIs +- Castr (or OBS multi-output) distributes the single RTMP stream to multiple platforms +- All credentials are stored locally and encrypted + + +## Quick Start (Docker) + +The easiest way to run Thrive Stream Controller is with Docker. + +### Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed +- [OBS Studio](https://obsproject.com/) with WebSocket enabled +- YouTube channel with live streaming enabled +- Facebook Page with persistent stream key configured + +### Setup + +```bash +# 1. Clone the repository +git clone https://github.com/ThriveCommunityChurch/Thrive_Stream_Controller.git +cd Thrive_Stream_Controller + +# 2. Create your environment file +cp .env.example .env + +# 3. Edit .env with your credentials (see Configuration section below) +notepad .env # Windows +nano .env # Linux/Mac + +# 4. Build and start the container +docker-compose up -d + +# 5. Open in browser +start http://localhost:8080 # Windows +open http://localhost:8080 # Mac +``` + +### First-Time YouTube Authorization + +After starting the app: + +1. Open http://localhost:8080 +2. Go to **Settings** +3. Click **Connect YouTube Account** +4. Complete the Google OAuth flow +5. The refresh token is stored encrypted – you won't need to do this again + +## Development Setup + +If you want to run the application without Docker for development: + +### Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) +- [Node.js 20+](https://nodejs.org/) +- [OBS Studio](https://obsproject.com/) + +### Running Locally + +**Terminal 1 – API:** +```bash +cd API/ThriveStreamController.API +dotnet run +``` + +**Terminal 2 – UI:** +```bash +cd UI +npm install +npm run dev +``` + +**Access:** +- UI: http://localhost:5173 +- API: http://localhost:5080 +- Swagger: http://localhost:5080/swagger + +## Configuration + +### Environment Variables + +Copy `.env.example` to `.env` and configure: + +| Variable | Description | Where to Get It | +|----------|-------------|-----------------| +| `OBS__WebSocketUrl` | OBS WebSocket URL | Default: `ws://host.docker.internal:4455` | +| `OBS__Password` | OBS WebSocket password | OBS → Tools → WebSocket Server Settings | +| `YouTube__ClientId` | Google OAuth Client ID | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) | +| `YouTube__ClientSecret` | Google OAuth Client Secret | Same as above | +| `Facebook__LiveProducerUrl` | Your Page's Live Producer URL | Facebook → Your Page → Live Video | + +### Google Cloud Setup (YouTube) + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project (or select existing) +3. Enable **YouTube Data API v3** +4. Go to **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID** +5. Application type: **Web application** +6. Add authorized redirect URI: `http://localhost:8080/api/auth/youtube/callback` +7. Copy the Client ID and Client Secret to your `.env` file + +### OBS Setup + +1. Open OBS Studio +2. Go to **Tools → WebSocket Server Settings** +3. Check **Enable WebSocket server** +4. Set port to **4455** (default) +5. Set a password (recommended) or leave blank +6. Click **Apply** + +### Facebook Setup + +Facebook requires a persistent stream key and one manual "Go Live" click: + +1. Go to your Facebook Page +2. Click **Live Video** or navigate to [facebook.com/live/producer](https://facebook.com/live/producer) +3. Select your Page +4. Go to **Settings** → Enable **Persistent Stream Key** +5. Configure OBS (or Castr) to send RTMP to this key +6. Save the Live Producer URL to your `.env` file + + +## Volunteer Workflow (Sunday Morning) + +This is the simple process volunteers follow each week: + +### Before Service + +1. ✅ Turn on the streaming PC +2. ✅ Open **OBS Studio** – verify correct profile and scenes are loaded +3. ✅ Open **Thrive Stream Controller** (http://localhost:8080) +4. ✅ Confirm OBS shows "Connected" in the dashboard + +### Starting the Stream + +1. Click **"▶ Start Stream (OBS + YouTube)"** +2. Wait for the status to show "LIVE" (takes ~15-30 seconds) +3. Click **"📺 Open Facebook to Go Live"** +4. In the Facebook browser tab: + - Verify preview video is showing + - Click the **"Go Live"** button + - Close the tab and return to the controller + +### During Service + +- Use OBS to switch scenes as needed +- Monitor audio levels in the dashboard +- The controller shows real-time streaming status + +### Ending the Stream + +1. Click **"⏹ End Stream"** +2. That's it! YouTube and Facebook will automatically end when the stream stops + +## Troubleshooting + +### OBS Shows "Not Connected" + +- Make sure OBS Studio is running +- Check that WebSocket server is enabled in OBS (Tools → WebSocket Server Settings) +- Verify the password in `.env` matches OBS +- Try restarting the Thrive Stream Controller + +### YouTube Won't Go Live + +- Check your internet connection +- Verify YouTube credentials are configured (Settings → YouTube status) +- Make sure your YouTube channel has live streaming enabled (requires 24-hour wait for new channels) +- Check the browser console for error details + +### Facebook Preview Shows No Video + +- Confirm OBS is actually streaming (check OBS status bar) +- Verify the correct OBS profile with Facebook's stream key is active +- Wait 10-15 seconds for the preview to appear +- If still no video, check your RTMP distribution service (Castr) or OBS output settings + +### Container Won't Start + +```bash +# Check logs for errors +docker-compose logs + +# Rebuild the container +docker-compose down +docker-compose up --build -d +``` + +## Project Structure + +``` +Thrive_Stream_Controller/ +├── API/ # .NET 9 Backend +│ ├── ThriveStreamController.API/ # Web API, Controllers, SignalR Hubs +│ ├── ThriveStreamController.Core/ # Business logic, Services, Models +│ └── ThriveStreamController.Data/ # Entity Framework, SQLite +├── UI/ # React 19 Frontend +│ └── src/ +│ ├── components/ # React components +│ ├── hooks/ # Custom React hooks +│ └── services/ # API client services +├── docker-compose.yml # Docker orchestration +├── Dockerfile # Multi-stage build +├── .env.example # Environment template +└── livestream-workflow.md # Detailed workflow documentation +``` + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Frontend** | React 19, TypeScript, Vite, TailwindCSS | +| **Backend** | .NET 9, ASP.NET Core, SignalR | +| **Database** | SQLite with Entity Framework Core | +| **OBS Integration** | Custom WebSocket client (OBS WebSocket Protocol v5) | +| **YouTube** | Google APIs Client Library for .NET | +| **Containerization** | Docker, Docker Compose | + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/obs/status` | GET | Get OBS connection status | +| `/api/obs/connect` | POST | Connect to OBS WebSocket | +| `/api/obs/scenes` | GET | List available scenes | +| `/api/obs/scenes/switch` | POST | Switch to a scene | +| `/api/obs/streaming/start` | POST | Start OBS streaming | +| `/api/obs/streaming/stop` | POST | Stop OBS streaming | +| `/api/auth/youtube/status` | GET | Get YouTube auth status | +| `/api/auth/youtube/authorize` | GET | Start OAuth flow | +| `/api/youtube/broadcast` | POST | Create new broadcast | +| `/api/youtube/broadcast/{id}/live` | POST | Transition to live | +| `/api/youtube/broadcast/{id}/end` | POST | End broadcast | + +Full API documentation available at `/swagger` when running in development mode. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- [OBS Studio](https://obsproject.com/) and the OBS WebSocket plugin team +- [Google YouTube Data API](https://developers.google.com/youtube/v3) +- Built with ❤️ for [Thrive Community Church](https://thrive-fl.org/) \ No newline at end of file diff --git a/UI/eslint.config.js b/UI/eslint.config.js new file mode 100644 index 0000000..b19330b --- /dev/null +++ b/UI/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/UI/index.html b/UI/index.html new file mode 100644 index 0000000..b3c24a7 --- /dev/null +++ b/UI/index.html @@ -0,0 +1,13 @@ + + + + + + + Thrive Stream Controller | Thrive Community Church - Estero, FL + + +
+ + + diff --git a/UI/package-lock.json b/UI/package-lock.json new file mode 100644 index 0000000..592a5df --- /dev/null +++ b/UI/package-lock.json @@ -0,0 +1,5031 @@ +{ + "name": "package.json", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "package.json", + "version": "0.0.0", + "dependencies": { + "@microsoft/signalr": "^8.0.0", + "@tanstack/react-query": "^5.90.16", + "axios": "^1.6.5", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@heroicons/react": "^2.1.1", + "@types/node": "^25.0.8", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.16", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@heroicons/react": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.1.tgz", + "integrity": "sha512-JyyN9Lo66kirbCMuMMRPtJxtKJoIsXKS569ebHGGRKbl8s4CtUfLnyKJxteA+vIKySocO4s1SkTkGS4xtG/yEA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.0.tgz", + "integrity": "sha512-K/wS/VmzRWePCGqGh8MU8OWbS1Zvu7DG7LSJS62fBB8rJUXwwj4axQtqrAAwKGUZHQF6CuteuQR9xMsVpM2JNA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.0.tgz", + "integrity": "sha512-WOHih+ClN7N8oHk9N4JUiMxQJmRVaOxcg8w7F/oHUXzJt920ekASLI/7cYX8XkntDWRhLZtsk6LbGrkgOAvi5A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", + "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz", + "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/type-utils": "8.46.0", + "@typescript-eslint/utils": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz", + "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz", + "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.0", + "@typescript-eslint/types": "^8.46.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz", + "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz", + "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz", + "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0", + "@typescript-eslint/utils": "8.46.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz", + "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz", + "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.0", + "@typescript-eslint/tsconfig-utils": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/visitor-keys": "8.46.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz", + "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.0", + "@typescript-eslint/types": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz", + "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.234", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", + "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.37.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz", + "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.0.tgz", + "integrity": "sha512-hGZ0HXbwz3zw52pLZV3j3+ec+m/PQ9cTpBvqjFQmy2XVUWGn5MD+31oXHb6dVTxYzmAeaiUBYjkoNz66n3RGCg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.14.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.0.tgz", + "integrity": "sha512-1dUdVj3cwc1npzJaf23gulB562ESNvxf7E4x8upNJycqyUm5BRRZ6dd3LrlzhtLaMrwOCO8R0zoiYxdaJx4LlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.14.0", + "react-router": "6.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.46.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz", + "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.46.0", + "@typescript-eslint/parser": "8.46.0", + "@typescript-eslint/typescript-estree": "8.46.0", + "@typescript-eslint/utils": "8.46.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/UI/package.json b/UI/package.json new file mode 100644 index 0000000..57b3957 --- /dev/null +++ b/UI/package.json @@ -0,0 +1,38 @@ +{ + "name": "package.json", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@microsoft/signalr": "^8.0.0", + "@tanstack/react-query": "^5.90.16", + "axios": "^1.6.5", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@heroicons/react": "^2.1.1", + "@types/node": "^25.0.8", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.16", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.7" + } +} diff --git a/UI/postcss.config.js b/UI/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/UI/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/UI/public/favicon.ico b/UI/public/favicon.ico new file mode 100644 index 0000000..6d75dc7 Binary files /dev/null and b/UI/public/favicon.ico differ diff --git a/UI/src/App.tsx b/UI/src/App.tsx new file mode 100644 index 0000000..40dff44 --- /dev/null +++ b/UI/src/App.tsx @@ -0,0 +1,7 @@ +import { Dashboard } from './components/Dashboard'; + +function App() { + return ; +} + +export default App; diff --git a/UI/src/components/AudioMeters.tsx b/UI/src/components/AudioMeters.tsx new file mode 100644 index 0000000..ffdd43e --- /dev/null +++ b/UI/src/components/AudioMeters.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState, useCallback } from 'react'; +import { signalRService } from '@/services/signalr.service'; +import type { AudioMeterSettings } from '@/hooks/useAudioMeterSettings'; + +interface InputVolumeMeter { + InputName: string; + InputUuid: string; + InputLevelsMul: number[]; + InputMuted: boolean; +} + +interface InputVolumeMetersData { + Inputs: InputVolumeMeter[]; +} + +interface AudioMetersProps { + isOBSConnected: boolean; + settings: AudioMeterSettings; +} + +/** + * AudioMeters component that displays real-time audio level meters for all OBS inputs + */ +export const AudioMeters: React.FC = ({ isOBSConnected, settings }) => { + const [volumeMeters, setVolumeMeters] = useState({ Inputs: [] }); + + /** + * Convert linear multiplier to decibels + */ + const mulToDb = useCallback((mul: number): number => { + if (mul <= 0) return -60; // Minimum dB + const db = 20 * Math.log10(mul); + return Math.max(-60, Math.min(0, db)); // Clamp between -60 and 0 + }, []); + + /** + * Get color for the meter based on dB level + */ + const getMeterColor = useCallback((db: number): string => { + if (db > -6) return 'bg-red-500'; // Red for > -6dB (danger zone) + if (db > -12) return 'bg-yellow-500'; // Yellow for -12dB to -6dB (warning) + return 'bg-green-500'; // Green for < -12dB (safe) + }, []); + + /** + * Calculate meter fill percentage (0-100) + */ + const getMeterPercentage = useCallback((db: number): number => { + // Map -60dB to 0dB -> 0% to 100% + return ((db + 60) / 60) * 100; + }, []); + + /** + * Subscribe to volume meters updates + */ + useEffect(() => { + if (!isOBSConnected) { + setVolumeMeters({ Inputs: [] }); + return; + } + + let logCounter = 0; + const handleVolumeMetersChanged = (data: InputVolumeMetersData) => { + // Log every 10000th update to help with debugging (events fire every 50ms = 20/sec) + logCounter++; + if (logCounter % 10000 === 0) { + console.log('[AudioMeters] Received volume meters:', { + inputCount: data.Inputs.length, + inputs: data.Inputs.map(i => ({ + name: i.InputName, + uuid: i.InputUuid, + channelCount: i.InputLevelsMul.length, + levels: i.InputLevelsMul + })) + }); + } + setVolumeMeters(data); + }; + + console.log('[AudioMeters] Subscribing to VolumeMetersChanged event'); + signalRService.on('VolumeMetersChanged', handleVolumeMetersChanged); + + return () => { + console.log('[AudioMeters] Unsubscribing from VolumeMetersChanged event'); + signalRService.off('VolumeMetersChanged', handleVolumeMetersChanged); + }; + }, [isOBSConnected]); + + if (!isOBSConnected) { + return ( +
+

Audio Meters

+

Connect to OBS to view audio levels

+
+ ); + } + + if (volumeMeters.Inputs.length === 0) { + return ( +
+

Audio Mixer

+

No active audio sources detected

+

+ Make sure you have audio sources enabled in OBS: +

+
    +
  • Desktop Audio
  • +
  • Mic/Aux
  • +
  • Media sources with audio
  • +
+
+ ); + } + + return ( +
+

+ Audio Mixer + + ({volumeMeters.Inputs.length} source{volumeMeters.Inputs.length !== 1 ? 's' : ''}) + +

+
+ {volumeMeters.Inputs.map((input) => { + return ( +
+ {/* Input Name */} +
+ + {input.InputName} + +
+ + {/* Channel Meters */} +
+ {input.InputLevelsMul.map((level, channelIdx) => { + const db = mulToDb(level); + const percentage = getMeterPercentage(db); + const color = getMeterColor(db); + const channelLabel = input.InputLevelsMul.length === 1 + ? 'Mono' + : (channelIdx === 0 ? 'L' : 'R'); + + return ( +
+ {/* Channel label and dB value */} +
+ + {channelLabel} + + {settings.showDbValues && ( + + {db.toFixed(1)} dB + + )} +
+ + {/* Meter Bar */} +
+ {/* Background gradient marks */} +
+
+
+
+
+
+ + {/* Meter fill */} +
+ {/* Shine effect */} +
+
+ + {/* Peak indicator line at -6dB (90% mark) */} +
+ + {/* dB Scale markers */} + {settings.showDbScale && ( +
+ {/* -60, -50, -40, -30, -20, -10, 0 */} + {[-50, -40, -30, -20, -10, -5].map((dbValue) => { + const position = ((dbValue + 60) / 60) * 100; + return ( +
+ {dbValue} +
+ ); + })} +
+ )} +
+
+ ); + })} +
+
+ ); + })} +
+ + {/* Legend */} +
+
+
+
+ Safe +
+
+
+ Warning +
+
+
+ Danger +
+
+
+
+ ); +}; + diff --git a/UI/src/components/ConnectionStatus.tsx b/UI/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..e11ed73 --- /dev/null +++ b/UI/src/components/ConnectionStatus.tsx @@ -0,0 +1,89 @@ +import { CheckCircleIcon, XCircleIcon, ArrowPathIcon } from '@heroicons/react/24/solid'; +import type { OBSConnectionStatus } from '../types/obs'; +import { SignalRConnectionState } from '@/types/obs'; + +interface ConnectionStatusProps { + signalRState: SignalRConnectionState; + obsStatus: OBSConnectionStatus; +} + +/** + * Component to display SignalR and OBS connection status + */ +export const ConnectionStatus: React.FC = ({ + signalRState, + obsStatus, +}) => { + const getSignalRIcon = () => { + switch (signalRState) { + case SignalRConnectionState.Connected: + return ; + case SignalRConnectionState.Connecting: + case SignalRConnectionState.Reconnecting: + return ; + case SignalRConnectionState.Disconnected: + case SignalRConnectionState.Disconnecting: + default: + return ; + } + }; + + const getSignalRStatusText = () => { + switch (signalRState) { + case SignalRConnectionState.Connected: + return 'Connected'; + case SignalRConnectionState.Connecting: + return 'Connecting...'; + case SignalRConnectionState.Reconnecting: + return 'Reconnecting...'; + case SignalRConnectionState.Disconnecting: + return 'Disconnecting...'; + case SignalRConnectionState.Disconnected: + default: + return 'Disconnected'; + } + }; + + const getOBSIcon = () => { + if (obsStatus.IsConnected) { + return ; + } + return ; + }; + + const getOBSStatusColor = () => { + return obsStatus.IsConnected ? 'text-green-700' : 'text-red-700'; + }; + + const bothConnected = signalRState === SignalRConnectionState.Connected && obsStatus.IsConnected; + + return ( +
+ {/* Status Icons Row */} +
+ {/* Backend Connection - Icon Only */} +
+ {getSignalRIcon()} +
+ + {/* OBS Connection - Icon + Label */} +
+ {getOBSIcon()} + + OBS Studio + +
+
+ + {/* Ready to Stream Message */} + {bothConnected && obsStatus.ServerUrl && ( +
+

+ Ready to stream via: {obsStatus.ServerUrl} +

+
+ )} +
+ ); +}; + diff --git a/UI/src/components/ControlsMenu.tsx b/UI/src/components/ControlsMenu.tsx new file mode 100644 index 0000000..0b7cf97 --- /dev/null +++ b/UI/src/components/ControlsMenu.tsx @@ -0,0 +1,148 @@ +import { useState, useRef, useEffect } from 'react'; +import { Bars3Icon, ArrowPathIcon } from '@heroicons/react/24/solid'; +import type { AudioMeterSettings } from '@/hooks/useAudioMeterSettings'; + +interface ControlsMenuProps { + isSignalRConnected: boolean; + isOBSConnected: boolean; + autoConnecting: boolean; + onConnect: () => void; + onDisconnect: () => void; + onRefreshScenes: () => void; + audioMeterSettings: AudioMeterSettings; + onAudioMeterSettingsChange: (updates: Partial) => void; +} + +/** + * Dropdown menu for OBS controls + */ +export const ControlsMenu: React.FC = ({ + isSignalRConnected, + isOBSConnected, + autoConnecting, + onConnect, + onDisconnect, + onRefreshScenes, + audioMeterSettings, + onAudioMeterSettingsChange, +}) => { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleMenuItemClick = (action: () => void) => { + action(); + setIsOpen(false); + }; + + return ( +
+ {/* Menu Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+ {/* Connect to OBS */} + + + {/* Disconnect from OBS */} + + + {/* Divider */} +
+ + {/* Refresh Scenes */} + + + {/* Divider */} +
+ + {/* Audio Meter Settings Section */} +
+

+ Audio Meter Settings +

+ + {/* Show dB Values */} + + + {/* Show dB Scale */} + +
+
+ )} +
+ ); +}; + diff --git a/UI/src/components/Dashboard.tsx b/UI/src/components/Dashboard.tsx new file mode 100644 index 0000000..df3bba3 --- /dev/null +++ b/UI/src/components/Dashboard.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { ConnectionStatus } from './ConnectionStatus'; +import { SceneSwitcher } from './SceneSwitcher'; +import { ControlsMenu } from './ControlsMenu'; +import { VideoPreview } from './VideoPreview'; +import { AudioMeters } from './AudioMeters'; +import { StreamingControls } from './StreamingControls'; +import { useSignalR } from '@/hooks/useSignalR'; +import { useOBSConnection } from '@/hooks/useOBSConnection'; +import { useAudioMeterSettings } from '@/hooks/useAudioMeterSettings'; +import { apiService } from '@/services/api.service'; +import { SignalRConnectionState } from '@/types/obs'; + +/** + * Main dashboard component integrating all features + */ +export const Dashboard: React.FC = () => { + const { connectionState, isConnected: isSignalRConnected } = useSignalR(); + const { obsStatus, currentScene } = useOBSConnection(); + const { settings: audioMeterSettings, updateSettings: updateAudioMeterSettings } = useAudioMeterSettings(); + const [autoConnecting, setAutoConnecting] = useState(false); + const [autoConnectError, setAutoConnectError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const [isYouTubeConfigured, setIsYouTubeConfigured] = useState(false); + + // Check YouTube configuration status + useEffect(() => { + const checkYouTubeStatus = async () => { + try { + const status = await apiService.youtubeAuthAPI.getStatus(); + setIsYouTubeConfigured(status.IsConfigured); + } catch { + setIsYouTubeConfigured(false); + } + }; + + checkYouTubeStatus(); + }, []); + + // Auto-connect to OBS when SignalR is connected + // Add a small delay to ensure SignalR connection is fully established + useEffect(() => { + // Don't auto-connect if already connected or currently connecting + if (obsStatus.IsConnected || autoConnecting) { + return; + } + + // Only auto-connect when SignalR is fully connected + if (!isSignalRConnected || connectionState !== SignalRConnectionState.Connected) { + return; + } + + // Add a small delay to ensure everything is ready + const timeoutId = setTimeout(async () => { + setAutoConnecting(true); + setAutoConnectError(null); + try { + console.log('Auto-connecting to OBS...'); + await apiService.obsAPI.connect(); + console.log('Auto-connect to OBS initiated'); + } catch (error) { + console.error('Failed to auto-connect to OBS:', error); + setAutoConnectError( + error instanceof Error ? error.message : 'Failed to connect to OBS' + ); + } finally { + setAutoConnecting(false); + } + }, 500); // 500ms delay to ensure SignalR is fully ready + + return () => clearTimeout(timeoutId); + }, [isSignalRConnected, obsStatus.IsConnected, connectionState]); + + const handleManualConnect = async () => { + setAutoConnecting(true); + setAutoConnectError(null); + try { + await apiService.obsAPI.connect(); + } catch (error) { + console.error('Failed to connect to OBS:', error); + setAutoConnectError( + error instanceof Error ? error.message : 'Failed to connect to OBS' + ); + } finally { + setAutoConnecting(false); + } + }; + + const handleDisconnect = async () => { + try { + await apiService.obsAPI.disconnect(); + } catch (error) { + console.error('Failed to disconnect from OBS:', error); + } + }; + + const handleRefreshScenes = useCallback(() => { + setRefreshKey(prev => prev + 1); + }, []); + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Thrive Stream Controller +

+

+ Manage your livestreams with ease +

+
+
+
+ + ⚙️ Configure Accounts + + +
+
+ + {/* Auto-connect error message */} + {autoConnectError && ( +
+

+ Connection Error: {autoConnectError} +

+ +
+ )} + + {/* Video Preview and Audio Meters Row */} +
+ {/* Video Preview - Takes 2 columns */} +
+
+

Program Out

+ +
+
+ + {/* Audio Meters - Takes 1 column */} +
+ +
+
+ + {/* Streaming Controls and Scene Switcher Row */} +
+ {/* Streaming Controls - Takes 1 column */} +
+ +
+ + {/* Scene Switcher - Takes 2 columns */} +
+ +
+
+ + {/* Footer */} +
+

Thrive Community Church Stream Controller © {new Date().getFullYear()} Thrive Community Church

+
+
+
+ ); +}; + diff --git a/UI/src/components/SceneSwitcher.tsx b/UI/src/components/SceneSwitcher.tsx new file mode 100644 index 0000000..c9a7f6e --- /dev/null +++ b/UI/src/components/SceneSwitcher.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect, useCallback } from 'react'; +import { VideoCameraIcon, ArrowPathIcon, ClockIcon } from '@heroicons/react/24/solid'; +import { apiService } from '@/services/api.service'; +import { useMediaStatus } from '@/hooks/useMediaStatus'; +import type { Scene } from '@/types/obs'; + +interface SceneSwitcherProps { + currentScene: string; + isOBSConnected: boolean; + refreshKey?: number; +} + +/** + * Component to display and switch between OBS scenes + */ +export const SceneSwitcher: React.FC = ({ + currentScene, + isOBSConnected, + refreshKey, +}) => { + const [scenes, setScenes] = useState([]); + const [loading, setLoading] = useState(false); + const [switching, setSwitching] = useState(null); + const [error, setError] = useState(null); + + // Use the media status hook to get real-time updates via SignalR + const { sceneMediaStatus } = useMediaStatus(); + + const fetchScenes = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await apiService.obsAPI.getScenes(); + setScenes(response.Scenes || []); + } catch (err) { + console.error('Error fetching scenes:', err); + setError('Failed to fetch scenes'); + } finally { + setLoading(false); + } + }, []); + + // Auto-fetch scenes when OBS connects, clear when disconnects + useEffect(() => { + if (isOBSConnected) { + fetchScenes(); + } else { + setScenes([]); + } + }, [isOBSConnected, fetchScenes]); + + // Refresh scenes when refreshKey changes + useEffect(() => { + if (refreshKey && refreshKey > 0 && isOBSConnected) { + fetchScenes(); + } + }, [refreshKey, isOBSConnected, fetchScenes]); + + // Media status is now handled by the useMediaStatus hook via SignalR + // No polling needed! + + // Update active scene when currentScene prop changes + useEffect(() => { + if (currentScene && scenes.length > 0) { + // Update the IsActive flag for all scenes based on currentScene + setScenes(prevScenes => + prevScenes.map(scene => ({ + ...scene, + IsActive: scene.Name === currentScene + })) + ); + } + }, [currentScene, scenes.length]); + + const handleSwitchScene = async (sceneName: string) => { + if (sceneName === currentScene) { + return; // Already on this scene + } + + setSwitching(sceneName); + setError(null); + try { + await apiService.obsAPI.switchScene(sceneName); + // The scene change will be reflected via SignalR event + } catch (err) { + console.error('Error switching scene:', err); + setError(`Failed to switch to ${sceneName}`); + } finally { + setSwitching(null); + } + }; + + // Format milliseconds to HH:MM:SS or MM:SS + const formatTime = (milliseconds: number): string => { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `-${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + if (minutes > 0) { + return `-${minutes}:${seconds.toString().padStart(2, '0')}`; + } + if (seconds > 0) { + return `-${seconds} seconds`; + } + return `ended`; + }; + + if (!isOBSConnected) { + return ( +
+

Scene Switcher

+
+ +

OBS is not connected

+

+ Connect to OBS to view and switch scenes +

+
+
+ ); + } + + return ( +
+
+

Scene Switcher

+
+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +
+ +

Loading scenes...

+
+ ) : !scenes || scenes.length === 0 ? ( +
+ +

No scenes loaded

+

+ Use the menu to refresh scenes from OBS +

+
+ ) : ( +
+ {scenes.map((scene) => { + const isCurrentScene = scene.Name === currentScene || scene.IsActive; + const isSwitching = switching === scene.Name; + const mediaStatus = sceneMediaStatus[scene.Name]?.Status; + const hasMediaTime = mediaStatus && mediaStatus.RemainingTime !== null && mediaStatus.RemainingTime > 0; + + return ( + + ); + })} +
+ )} +
+ ); +}; + diff --git a/UI/src/components/StreamingControls.tsx b/UI/src/components/StreamingControls.tsx new file mode 100644 index 0000000..1ad95c4 --- /dev/null +++ b/UI/src/components/StreamingControls.tsx @@ -0,0 +1,312 @@ +import { useState, useEffect, useCallback } from 'react'; +import { apiService, type YouTubeBroadcastInfo } from '@/services/api.service'; + +interface StreamingControlsProps { + isOBSConnected: boolean; + isYouTubeConfigured: boolean; + facebookLiveProducerUrl?: string; +} + +type StreamingPhase = + | 'idle' + | 'creating_broadcast' + | 'binding_stream' + | 'starting_obs' + | 'transitioning_live' + | 'live' + | 'ending' + | 'error'; + +/** + * Streaming controls component for volunteer-friendly stream management + */ +export const StreamingControls: React.FC = ({ + isOBSConnected, + isYouTubeConfigured, + facebookLiveProducerUrl = 'https://www.facebook.com/live/producer', +}) => { + const [phase, setPhase] = useState('idle'); + const [currentBroadcast, setCurrentBroadcast] = useState(null); + const [broadcastTitle, setBroadcastTitle] = useState(''); + const [broadcastDescription, setBroadcastDescription] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Load defaults on mount + useEffect(() => { + const loadDefaults = async () => { + if (!isYouTubeConfigured) return; + + try { + const defaults = await apiService.youtubeLiveAPI.getDefaults(); + if (defaults.TitleTemplate) { + // Replace date placeholder if present + const today = new Date().toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }); + setBroadcastTitle(defaults.TitleTemplate.replace('{date}', today)); + } + if (defaults.Description) { + setBroadcastDescription(defaults.Description); + } + } catch (err) { + console.error('Failed to load defaults:', err); + } + }; + + loadDefaults(); + }, [isYouTubeConfigured]); + + // Check for active broadcast on mount + useEffect(() => { + const checkActiveBroadcast = async () => { + if (!isYouTubeConfigured) return; + + try { + const active = await apiService.youtubeLiveAPI.getActiveBroadcast(); + if (active) { + setCurrentBroadcast(active); + setPhase('live'); + } + } catch { + // No active broadcast + } + }; + + checkActiveBroadcast(); + }, [isYouTubeConfigured]); + + const handleStartStream = useCallback(async () => { + if (!isOBSConnected || !isYouTubeConfigured) return; + + setIsLoading(true); + setError(null); + + try { + // Phase 1: Create broadcast + setPhase('creating_broadcast'); + const broadcast = await apiService.youtubeLiveAPI.createBroadcast( + broadcastTitle || 'Sunday Worship Service', + broadcastDescription + ); + setCurrentBroadcast(broadcast); + + // Phase 2: Bind to persistent stream + setPhase('binding_stream'); + await apiService.youtubeLiveAPI.bindBroadcast(broadcast.Id); + + // Phase 3: Start OBS streaming + setPhase('starting_obs'); + await apiService.obsAPI.startStreaming(); + + // Wait a moment for stream to connect + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Phase 4: Transition to testing first (YouTube requirement) + await apiService.youtubeLiveAPI.transitionToTesting(broadcast.Id); + + // Wait for testing status + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Phase 5: Transition to live + setPhase('transitioning_live'); + const liveBroadcast = await apiService.youtubeLiveAPI.transitionToLive(broadcast.Id); + setCurrentBroadcast(liveBroadcast); + + setPhase('live'); + } catch (err) { + console.error('Failed to start stream:', err); + setError(err instanceof Error ? err.message : 'Failed to start stream'); + setPhase('error'); + } finally { + setIsLoading(false); + } + }, [isOBSConnected, isYouTubeConfigured, broadcastTitle, broadcastDescription]); + + const handleEndStream = useCallback(async () => { + if (!currentBroadcast) return; + + setIsLoading(true); + setError(null); + + try { + setPhase('ending'); + + // Stop OBS streaming first + await apiService.obsAPI.stopStreaming(); + + // End YouTube broadcast + await apiService.youtubeLiveAPI.endBroadcast(currentBroadcast.Id); + + setCurrentBroadcast(null); + setPhase('idle'); + } catch (err) { + console.error('Failed to end stream:', err); + setError(err instanceof Error ? err.message : 'Failed to end stream'); + setPhase('error'); + } finally { + setIsLoading(false); + } + }, [currentBroadcast]); + + const handleOpenFacebook = useCallback(() => { + window.open(facebookLiveProducerUrl, '_blank'); + }, [facebookLiveProducerUrl]); + + const getPhaseMessage = (): string => { + switch (phase) { + case 'creating_broadcast': return 'Creating YouTube broadcast...'; + case 'binding_stream': return 'Binding to stream...'; + case 'starting_obs': return 'Starting OBS stream...'; + case 'transitioning_live': return 'Going live on YouTube...'; + case 'live': return 'LIVE'; + case 'ending': return 'Ending stream...'; + case 'error': return 'Error'; + default: return 'Ready'; + } + }; + + const isStreaming = phase === 'live'; + const canStart = phase === 'idle' && isOBSConnected && isYouTubeConfigured && !isLoading; + const canEnd = isStreaming && !isLoading; + + return ( +
+

Stream Controls

+ + {/* Status Indicator */} +
+
+ + Status: + {getPhaseMessage()} + + + {isStreaming && ( + + + ● REC + + )} +
+ {currentBroadcast?.WatchUrl && ( + + {currentBroadcast.WatchUrl} + + )} +
+ + {/* Error Display */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Broadcast Settings (only show when not streaming) */} + {!isStreaming && phase === 'idle' && ( +
+
+ + setBroadcastTitle(e.target.value)} + placeholder="Sunday Worship Service" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" + /> +
+
+ +