From afea2f21461a01a4c29a2bb8234d542e8bb429b6 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Mon, 13 Oct 2025 10:32:20 -0400 Subject: [PATCH 01/13] Update .gitignore --- .gitignore | 432 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 432 insertions(+) diff --git a/.gitignore b/.gitignore index 9a5aced..d115bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# Node + # Logs logs *.log @@ -137,3 +139,433 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Dotnet +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp \ No newline at end of file From 2f0ae73ad4f3cbc7a3af6cabd3c68719f95f5c43 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Mon, 13 Oct 2025 16:51:00 -0400 Subject: [PATCH 02/13] Init project - Adds OBS WebSocket integration Implements a full API for managing OBS connections and interactions, enabling seamless control of streaming, scene switching, and status monitoring. Introduces a structured architecture with services, controllers, and SignalR for real-time communication, enhancing user experience in managing live streams. Fixes issues with basic connection management and adds functionality for scene management and media status tracking. Optimizing OBS integration for the Thrive Live Stream. --- .gitignore | 522 +- API/README.md | 112 + .../Controllers/OBSController.cs | 371 ++ API/ThriveStreamController.API/Hubs/OBSHub.cs | 65 + API/ThriveStreamController.API/Program.cs | 106 + .../Properties/launchSettings.json | 23 + .../Services/MediaStatusBroadcaster.cs | 46 + .../MediaStatusTrackerHostedService.cs | 33 + .../Services/OBSEventBroadcaster.cs | 110 + .../ThriveStreamController.API.csproj | 25 + .../appsettings.Development.json | 11 + .../appsettings.json | 43 + .../Interfaces/IOBSService.cs | 97 + .../Models/MediaInputStatus.cs | 42 + .../Models/OBSConnectionStatus.cs | 34 + .../Models/OBSScene.cs | 24 + .../Models/Requests/SwitchSceneRequest.cs | 36 + .../Models/Requests/TestConnectionRequest.cs | 54 + .../Models/SceneItem.cs | 39 + .../Models/SceneMediaStatus.cs | 24 + .../Models/ScenesResponse.cs | 19 + .../Models/StreamingStatus.cs | 34 + .../Services/MediaStatusTracker.cs | 202 + .../Services/OBSService.cs | 652 +++ .../Services/OBSWebSocketClient.cs | 464 ++ .../System/SystemMessages.cs | 32 + .../System/SystemResponseBase.cs | 24 + .../System/ValidationResponse.cs | 32 + .../ThriveStreamController.Core.csproj | 17 + .../ApplicationDbContext.cs | 91 + .../ThriveStreamController.Data.csproj | 13 + .../ThriveStreamController.Tests.csproj | 28 + API/ThriveStreamController.Tests/UnitTest1.cs | 10 + API/ThriveStreamController.sln | 76 + UI/eslint.config.js | 23 + UI/index.html | 13 + UI/package-lock.json | 5031 +++++++++++++++++ UI/package.json | 38 + UI/postcss.config.js | 6 + UI/public/favicon.ico | Bin 0 -> 1134 bytes UI/src/App.tsx | 7 + UI/src/components/ConnectionStatus.tsx | 89 + UI/src/components/ControlsMenu.tsx | 105 + UI/src/components/Dashboard.tsx | 145 + UI/src/components/SceneSwitcher.tsx | 216 + UI/src/hooks/useMediaStatus.ts | 36 + UI/src/hooks/useOBSConnection.ts | 57 + UI/src/hooks/useSignalR.ts | 82 + UI/src/index.css | 3 + UI/src/main.tsx | 10 + UI/src/services/api.service.ts | 114 + UI/src/services/signalr.service.ts | 179 + UI/src/types/obs.ts | 83 + UI/tailwind.config.js | 12 + UI/tsconfig.app.json | 34 + UI/tsconfig.json | 7 + UI/tsconfig.node.json | 26 + UI/vite.config.ts | 29 + 58 files changed, 9412 insertions(+), 444 deletions(-) create mode 100644 API/README.md create mode 100644 API/ThriveStreamController.API/Controllers/OBSController.cs create mode 100644 API/ThriveStreamController.API/Hubs/OBSHub.cs create mode 100644 API/ThriveStreamController.API/Program.cs create mode 100644 API/ThriveStreamController.API/Properties/launchSettings.json create mode 100644 API/ThriveStreamController.API/Services/MediaStatusBroadcaster.cs create mode 100644 API/ThriveStreamController.API/Services/MediaStatusTrackerHostedService.cs create mode 100644 API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs create mode 100644 API/ThriveStreamController.API/ThriveStreamController.API.csproj create mode 100644 API/ThriveStreamController.API/appsettings.Development.json create mode 100644 API/ThriveStreamController.API/appsettings.json create mode 100644 API/ThriveStreamController.Core/Interfaces/IOBSService.cs create mode 100644 API/ThriveStreamController.Core/Models/MediaInputStatus.cs create mode 100644 API/ThriveStreamController.Core/Models/OBSConnectionStatus.cs create mode 100644 API/ThriveStreamController.Core/Models/OBSScene.cs create mode 100644 API/ThriveStreamController.Core/Models/Requests/SwitchSceneRequest.cs create mode 100644 API/ThriveStreamController.Core/Models/Requests/TestConnectionRequest.cs create mode 100644 API/ThriveStreamController.Core/Models/SceneItem.cs create mode 100644 API/ThriveStreamController.Core/Models/SceneMediaStatus.cs create mode 100644 API/ThriveStreamController.Core/Models/ScenesResponse.cs create mode 100644 API/ThriveStreamController.Core/Models/StreamingStatus.cs create mode 100644 API/ThriveStreamController.Core/Services/MediaStatusTracker.cs create mode 100644 API/ThriveStreamController.Core/Services/OBSService.cs create mode 100644 API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs create mode 100644 API/ThriveStreamController.Core/System/SystemMessages.cs create mode 100644 API/ThriveStreamController.Core/System/SystemResponseBase.cs create mode 100644 API/ThriveStreamController.Core/System/ValidationResponse.cs create mode 100644 API/ThriveStreamController.Core/ThriveStreamController.Core.csproj create mode 100644 API/ThriveStreamController.Data/ApplicationDbContext.cs create mode 100644 API/ThriveStreamController.Data/ThriveStreamController.Data.csproj create mode 100644 API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj create mode 100644 API/ThriveStreamController.Tests/UnitTest1.cs create mode 100644 API/ThriveStreamController.sln create mode 100644 UI/eslint.config.js create mode 100644 UI/index.html create mode 100644 UI/package-lock.json create mode 100644 UI/package.json create mode 100644 UI/postcss.config.js create mode 100644 UI/public/favicon.ico create mode 100644 UI/src/App.tsx create mode 100644 UI/src/components/ConnectionStatus.tsx create mode 100644 UI/src/components/ControlsMenu.tsx create mode 100644 UI/src/components/Dashboard.tsx create mode 100644 UI/src/components/SceneSwitcher.tsx create mode 100644 UI/src/hooks/useMediaStatus.ts create mode 100644 UI/src/hooks/useOBSConnection.ts create mode 100644 UI/src/hooks/useSignalR.ts create mode 100644 UI/src/index.css create mode 100644 UI/src/main.tsx create mode 100644 UI/src/services/api.service.ts create mode 100644 UI/src/services/signalr.service.ts create mode 100644 UI/src/types/obs.ts create mode 100644 UI/tailwind.config.js create mode 100644 UI/tsconfig.app.json create mode 100644 UI/tsconfig.json create mode 100644 UI/tsconfig.node.json create mode 100644 UI/vite.config.ts diff --git a/.gitignore b/.gitignore index d115bc9..d38ed8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ -# Node - # 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 @@ -21,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 @@ -68,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 @@ -94,8 +94,8 @@ dist .vuepress/dist # vuepress v2.x temp and cache directory -.temp -.cache +*.temp +*.cache # Sveltekit cache directory .svelte-kit/ @@ -128,444 +128,78 @@ 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-* - -# Dotnet -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. +# 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/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* +## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore # Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ +*[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/ +*project.lock.json +*project.fragment.lock.json +*artifacts/ +*.vs # ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ +*ScaffoldingReadMe.txt # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ # Others -ClientBin/ ~$* *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ +*CodeCoverage/ # MSBuild Binary and Structured Log *.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ +# MSTest test Results +*[Tt]est[Rr]esult*/ +*[Bb]uild[Ll]og.* -# Built Visual Studio Code Extensions -*.vsix +# NUnit +*.VisualState.xml +*TestResult.xml +*nunit-*.xml -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp \ No newline at end of file +# DB files +*.db +*.db-wal +*.db-shm +*.user 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..1b6322f --- /dev/null +++ b/API/ThriveStreamController.API/Controllers/OBSController.cs @@ -0,0 +1,371 @@ +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 }); + } + } + } + +} + 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..0cefcc8 --- /dev/null +++ b/API/ThriveStreamController.API/Program.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Serilog; +using ThriveStreamController.API.Hubs; +using ThriveStreamController.API.Services; +using ThriveStreamController.Core.Interfaces; +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)); + + // Register application services + 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"); + + app.UseAuthorization(); + + app.MapControllers(); + app.MapHub("/hubs/obs"); + + // 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..fe6e167 --- /dev/null +++ b/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs @@ -0,0 +1,110 @@ +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; + + _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; + + _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"); + } + } +} + 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/IOBSService.cs b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs new file mode 100644 index 0000000..2bee5fd --- /dev/null +++ b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs @@ -0,0 +1,97 @@ +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; + + /// + /// 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); + } +} + 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/Services/MediaStatusTracker.cs b/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs new file mode 100644 index 0000000..d5b95af --- /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.LogInformation("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..12bf6c8 --- /dev/null +++ b/API/ThriveStreamController.Core/Services/OBSService.cs @@ -0,0 +1,652 @@ +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; + + /// + /// 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; + + /// + /// 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 []; + } + + _logger.LogInformation("Fetching scenes from OBS..."); + + // 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.LogInformation("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"); + } + + private void OnDisconnected(object? sender, EventArgs e) + { + _logger.LogWarning("OBS WebSocket disconnected event received"); + + lock (_statusLock) + { + _connectionStatus.IsConnected = false; + } + + ConnectionStatusChanged?.Invoke(this, _connectionStatus); + } + + private void OnEventReceived(object? sender, JObject eventData) + { + 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; + } + } + + /// + /// 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.LogInformation("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.LogInformation("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.LogInformation("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.LogInformation("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; + } + } + + 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..bff03b9 --- /dev/null +++ b/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs @@ -0,0 +1,464 @@ +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.LogInformation("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.LogInformation("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) + var identifyData = new JObject + { + ["rpcVersion"] = rpcVersion, + ["eventSubscriptions"] = 2047 // Subscribe to all non-high-volume events + }; + + // 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.LogInformation("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.LogInformation("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/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..32db17c --- /dev/null +++ b/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net9.0 + enable + enable + + + diff --git a/API/ThriveStreamController.Data/ApplicationDbContext.cs b/API/ThriveStreamController.Data/ApplicationDbContext.cs new file mode 100644 index 0000000..ee2d747 --- /dev/null +++ b/API/ThriveStreamController.Data/ApplicationDbContext.cs @@ -0,0 +1,91 @@ +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; } + + /// + /// 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/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/ThriveStreamController.Tests.csproj b/API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj new file mode 100644 index 0000000..ffd81a6 --- /dev/null +++ b/API/ThriveStreamController.Tests/ThriveStreamController.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/API/ThriveStreamController.Tests/UnitTest1.cs b/API/ThriveStreamController.Tests/UnitTest1.cs new file mode 100644 index 0000000..ffa6703 --- /dev/null +++ b/API/ThriveStreamController.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace ThriveStreamController.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} 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/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..0fa1558 --- /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.17.0", + "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": "^20.10.6", + "@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.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.17.0.tgz", + "integrity": "sha512-LoBaPtbMY26kRS+ohII4thTsWkJJsXKGitOLikTo2aqPA4yy7cfFJITs8DRnuERT7tLF5xfG9Lnm33Vp/38Vmw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.17.0.tgz", + "integrity": "sha512-iNSn6ZA7mHUjrT0a271eKoa1oR1HznlrGbb475awft1kuP3zrhyUCrI8tlGowOr7zRoAxJholjwxO+gfz1IObw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "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": "20.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", + "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "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": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "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..1d94a75 --- /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.17.0", + "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": "^20.10.6", + "@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 0000000000000000000000000000000000000000..6d75dc7c06bb63973ffed8f545cfad4252ca4235 GIT binary patch literal 1134 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabRA=0V0;kZ6XNP#;1hB1e#D^%;d^fb z84dscgSbGn|9;frhfwyR`waj8haY?Z;vNE%AO=_&SaIZm2Qfes!0d>F4r{2+Ay{n(u^8XEqG zUilw(=waN+Ct&LygzpF06}swwwGxEr?b4$u~uW9~pqh=4j6X!8BYgAc+% zE(DT5lYt%qIWPPG(5`zRjZkObk38})bnj!Z%YiNgB9LC7Y$Q-6kOA`Sg9wnW`w{!@ zh8}wiba3eYD-j3o0G$-NY86Q1p$BPao`DpA6^9?VAGP;h^ufmwhkzE{1G>Rs|9_Y} zK}G>XHS#df3Xu8HAcui8h6B04Py(ul1TrvKz~LOZALNm+eIWHf1~7=i58aE{4{{GM ztROA~+6d%=On^8TOv2*gK1aBzBrwG1mjw9*Gd$d^{Ez9+O9jF2KQkDEel6~J_jkeD zotBb2F8+>}SJcyaQS^xQI^%Nvugm?mMWq;UGqG6k~v1a(|X_A=T;}WW7qvk zaO%u9ikUKF(wu`&kJZ^ozc(!T{BL^hfv0mP-goAV>$u``BIRE$n>B+4u$0~aCiTyd#cl?1>D;)y=J%Z&J=aazTeM9D>iN` zy_ETJ`8!dD6iur%7J@Oyiw*r1no=`0>H1RF?No-dst#vh*2$f4;8%e%}9;`)ZsDh5wiKbLh*2~7ZF+-t@F literal 0 HcmV?d00001 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/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..536e340 --- /dev/null +++ b/UI/src/components/ControlsMenu.tsx @@ -0,0 +1,105 @@ +import { useState, useRef, useEffect } from 'react'; +import { Bars3Icon, ArrowPathIcon } from '@heroicons/react/24/solid'; + +interface ControlsMenuProps { + isSignalRConnected: boolean; + isOBSConnected: boolean; + autoConnecting: boolean; + onConnect: () => void; + onDisconnect: () => void; + onRefreshScenes: () => void; +} + +/** + * Dropdown menu for OBS controls + */ +export const ControlsMenu: React.FC = ({ + isSignalRConnected, + isOBSConnected, + autoConnecting, + onConnect, + onDisconnect, + onRefreshScenes, +}) => { + 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 */} + +
+ )} +
+ ); +}; + diff --git a/UI/src/components/Dashboard.tsx b/UI/src/components/Dashboard.tsx new file mode 100644 index 0000000..4aedfe8 --- /dev/null +++ b/UI/src/components/Dashboard.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState, useCallback } from 'react'; +import { ConnectionStatus } from './ConnectionStatus'; +import { SceneSwitcher } from './SceneSwitcher'; +import { ControlsMenu } from './ControlsMenu'; +import { useSignalR } from '@/hooks/useSignalR'; +import { useOBSConnection } from '@/hooks/useOBSConnection'; +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 [autoConnecting, setAutoConnecting] = useState(false); + const [autoConnectError, setAutoConnectError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + // 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 OBS Studio livestreams with ease +

+
+
+ +
+ + {/* Auto-connect error message */} + {autoConnectError && ( +
+

+ Connection Error: {autoConnectError} +

+ +
+ )} + + + + {/* Scene Switcher */} +
+ +
+ + {/* 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/hooks/useMediaStatus.ts b/UI/src/hooks/useMediaStatus.ts new file mode 100644 index 0000000..b76047a --- /dev/null +++ b/UI/src/hooks/useMediaStatus.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { signalRService } from '@/services/signalr.service'; +import type { SceneMediaStatus } from '@/types/obs'; + +/** + * Hook for listening to media status updates via SignalR + */ +export const useMediaStatus = () => { + const [sceneMediaStatus, setSceneMediaStatus] = useState>({}); + + useEffect(() => { + // Handler for media status changes + const handleMediaStatusChanged = (status: SceneMediaStatus) => { + setSceneMediaStatus(prev => ({ + ...prev, + [status.SceneName]: status + })); + }; + + console.log('[useMediaStatus] Subscribing to MediaStatusChanged event'); + + // Subscribe to SignalR event + signalRService.on('MediaStatusChanged', handleMediaStatusChanged); + + // Cleanup: unsubscribe from event + return () => { + console.log('[useMediaStatus] Unsubscribing from MediaStatusChanged event'); + signalRService.off('MediaStatusChanged', handleMediaStatusChanged); + }; + }, []); + + return { + sceneMediaStatus, + }; +}; + diff --git a/UI/src/hooks/useOBSConnection.ts b/UI/src/hooks/useOBSConnection.ts new file mode 100644 index 0000000..273aecb --- /dev/null +++ b/UI/src/hooks/useOBSConnection.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { signalRService } from '@/services/signalr.service'; +import type { OBSConnectionStatus } from '@/types/obs'; + +/** + * Hook for listening to OBS connection and scene change events via SignalR + */ +export const useOBSConnection = () => { + const [obsStatus, setObsStatus] = useState({ + IsConnected: false, + }); + const [currentScene, setCurrentScene] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + + useEffect(() => { + // Handler for connection status changes + const handleConnectionStatusChanged = (status: OBSConnectionStatus) => { + console.log('[useOBSConnection] OBS connection status changed:', status); + console.log('[useOBSConnection] isConnected:', status.IsConnected); + setObsStatus(status); + }; + + // Handler for scene changes + const handleSceneChanged = (sceneName: string) => { + console.log('[useOBSConnection] OBS scene changed:', sceneName); + setCurrentScene(sceneName); + }; + + // Handler for streaming status changes + const handleStreamingStatusChanged = (streaming: boolean) => { + console.log('[useOBSConnection] OBS streaming status changed:', streaming); + setIsStreaming(streaming); + }; + + console.log('[useOBSConnection] Subscribing to SignalR events'); + + // Subscribe to SignalR events + signalRService.onConnectionStatusChanged(handleConnectionStatusChanged); + signalRService.onSceneChanged(handleSceneChanged); + signalRService.onStreamingStatusChanged(handleStreamingStatusChanged); + + // Cleanup: unsubscribe from events + return () => { + console.log('[useOBSConnection] Unsubscribing from SignalR events'); + signalRService.off('ConnectionStatusChanged', handleConnectionStatusChanged); + signalRService.off('SceneChanged', handleSceneChanged); + signalRService.off('StreamingStatusChanged', handleStreamingStatusChanged); + }; + }, []); + + return { + obsStatus, + currentScene, + isStreaming, + }; +}; + diff --git a/UI/src/hooks/useSignalR.ts b/UI/src/hooks/useSignalR.ts new file mode 100644 index 0000000..198ad7b --- /dev/null +++ b/UI/src/hooks/useSignalR.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { HubConnectionState } from '@microsoft/signalr'; +import { signalRService } from '@/services/signalr.service'; +import { SignalRConnectionState } from '@/types/obs'; + +/** + * Hook for managing SignalR connection state + */ +export const useSignalR = () => { + const [connectionState, setConnectionState] = useState( + SignalRConnectionState.Disconnected + ); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const initializeConnection = async () => { + try { + setConnectionState(SignalRConnectionState.Connecting); + await signalRService.start(); + + if (mounted) { + setConnectionState(SignalRConnectionState.Connected); + setError(null); + } + } catch (err) { + console.error('Failed to start SignalR connection:', err); + if (mounted) { + setConnectionState(SignalRConnectionState.Disconnected); + setError(err instanceof Error ? err.message : 'Failed to connect'); + } + } + }; + + // Check connection state periodically + const checkConnectionState = () => { + const state = signalRService.getConnectionState(); + + if (!mounted) return; + + switch (state) { + case HubConnectionState.Connected: + setConnectionState(SignalRConnectionState.Connected); + setError(null); + break; + case HubConnectionState.Connecting: + setConnectionState(SignalRConnectionState.Connecting); + break; + case HubConnectionState.Reconnecting: + setConnectionState(SignalRConnectionState.Reconnecting); + break; + case HubConnectionState.Disconnecting: + setConnectionState(SignalRConnectionState.Disconnecting); + break; + case HubConnectionState.Disconnected: + setConnectionState(SignalRConnectionState.Disconnected); + break; + } + }; + + initializeConnection(); + + // Poll connection state every 2 seconds + const intervalId = setInterval(checkConnectionState, 2000); + + return () => { + mounted = false; + clearInterval(intervalId); + // Don't stop the singleton SignalR service on component unmount + // This prevents React StrictMode from breaking the connection during development + // The service will maintain its connection across component lifecycles + }; + }, []); + + return { + connectionState, + isConnected: connectionState === SignalRConnectionState.Connected, + error, + }; +}; + diff --git a/UI/src/index.css b/UI/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/UI/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/UI/src/main.tsx b/UI/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/UI/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/UI/src/services/api.service.ts b/UI/src/services/api.service.ts new file mode 100644 index 0000000..270d4c8 --- /dev/null +++ b/UI/src/services/api.service.ts @@ -0,0 +1,114 @@ +import axios from 'axios'; +import type { AxiosInstance } from 'axios'; +import type { OBSConnectionStatus, ScenesResponse, SwitchSceneRequest, StreamingStatus, SceneItem, MediaInputStatus } from '@/types/obs'; + +/** + * API service for making HTTP requests to the backend + */ +class ApiService { + private axiosInstance: AxiosInstance; + + constructor() { + this.axiosInstance = axios.create({ + baseURL: '/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Add response interceptor for error handling + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error); + return Promise.reject(error); + } + ); + } + + /** + * OBS API methods + */ + public obsAPI = { + /** + * Connect to OBS WebSocket + */ + connect: async (): Promise => { + await this.axiosInstance.post('/OBS/connect'); + }, + + /** + * Disconnect from OBS WebSocket + */ + disconnect: async (): Promise => { + await this.axiosInstance.post('/OBS/disconnect'); + }, + + /** + * Get OBS connection status + */ + getStatus: async (): Promise => { + const response = await this.axiosInstance.get('/OBS/status'); + return response.data; + }, + + /** + * Get list of OBS scenes + */ + getScenes: async (): Promise => { + const response = await this.axiosInstance.get('/OBS/scenes'); + return response.data; + }, + + /** + * Switch to a different OBS scene + */ + switchScene: async (sceneName: string): Promise => { + const request: SwitchSceneRequest = { SceneName: sceneName }; + await this.axiosInstance.post('/OBS/scenes/switch', request); + }, + + /** + * Get streaming status + */ + getStreamingStatus: async (): Promise => { + const response = await this.axiosInstance.get('/OBS/streaming/status'); + return response.data; + }, + + /** + * Start streaming + */ + startStreaming: async (): Promise => { + await this.axiosInstance.post('/OBS/streaming/start'); + }, + + /** + * Stop streaming + */ + stopStreaming: async (): Promise => { + await this.axiosInstance.post('/OBS/streaming/stop'); + }, + + /** + * Get scene items for a specific scene + */ + getSceneItems: async (sceneName: string): Promise => { + const response = await this.axiosInstance.get(`/OBS/scenes/${encodeURIComponent(sceneName)}/items`); + return response.data; + }, + + /** + * Get media input status for a specific input + */ + getMediaInputStatus: async (inputName: string): Promise => { + const response = await this.axiosInstance.get(`/OBS/media/${encodeURIComponent(inputName)}/status`); + return response.data; + }, + }; +} + +// Export a singleton instance +export const apiService = new ApiService(); + diff --git a/UI/src/services/signalr.service.ts b/UI/src/services/signalr.service.ts new file mode 100644 index 0000000..5ab267c --- /dev/null +++ b/UI/src/services/signalr.service.ts @@ -0,0 +1,179 @@ +import * as signalR from '@microsoft/signalr'; +import type { OBSConnectionStatus, Scene } from '@/types/obs'; + +/** + * SignalR service for real-time communication with the backend + */ +class SignalRService { + private connection: signalR.HubConnection | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 10; + private reconnectDelay = 5000; // 5 seconds + + /** + * Initialize and start the SignalR connection + */ + public async start(): Promise { + if (this.connection) { + console.log('SignalR connection already exists'); + return; + } + + // Use absolute URL to connect directly to backend, bypassing Vite proxy + const backendUrl = 'http://localhost:5080'; + + this.connection = new signalR.HubConnectionBuilder() + .withUrl(`${backendUrl}/hubs/obs`, { + skipNegotiation: false, + transport: signalR.HttpTransportType.WebSockets | signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling, + }) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + if (retryContext.previousRetryCount >= this.maxReconnectAttempts) { + console.error('Max reconnect attempts reached'); + return null; // Stop reconnecting + } + return this.reconnectDelay; + }, + }) + .configureLogging(signalR.LogLevel.Information) + .build(); + + // Set up connection event handlers + this.connection.onclose((error) => { + console.log('SignalR connection closed', error); + this.reconnectAttempts = 0; + }); + + this.connection.onreconnecting((error) => { + console.log('SignalR reconnecting...', error); + this.reconnectAttempts++; + }); + + this.connection.onreconnected((connectionId) => { + console.log('SignalR reconnected', connectionId); + this.reconnectAttempts = 0; + }); + + try { + await this.connection.start(); + console.log('SignalR connection started successfully'); + } catch (error) { + console.error('Error starting SignalR connection:', error); + throw error; + } + } + + /** + * Stop the SignalR connection + */ + public async stop(): Promise { + if (this.connection) { + try { + await this.connection.stop(); + console.log('SignalR connection stopped'); + } catch (error) { + console.error('Error stopping SignalR connection:', error); + } finally { + this.connection = null; + } + } + } + + /** + * Get the current connection state + */ + public getConnectionState(): signalR.HubConnectionState { + return this.connection?.state ?? signalR.HubConnectionState.Disconnected; + } + + /** + * Subscribe to OBS connection status changes + */ + public onConnectionStatusChanged(callback: (status: OBSConnectionStatus) => void): void { + if (!this.connection) { + console.warn('[SignalR] Connection not initialized'); + return; + } + + console.log('[SignalR] Subscribing to ConnectionStatusChanged event'); + this.connection.on('ConnectionStatusChanged', (status: OBSConnectionStatus) => { + console.log('[SignalR] Received ConnectionStatusChanged event:', status); + callback(status); + }); + } + + /** + * Subscribe to OBS scene changes + */ + public onSceneChanged(callback: (sceneName: string) => void): void { + if (!this.connection) { + console.warn('SignalR connection not initialized'); + return; + } + + this.connection.on('SceneChanged', callback); + } + + /** + * Subscribe to streaming status changes + */ + public onStreamingStatusChanged(callback: (isStreaming: boolean) => void): void { + if (!this.connection) { + console.warn('SignalR connection not initialized'); + return; + } + + this.connection.on('StreamingStatusChanged', callback); + } + + /** + * Subscribe to a generic event + */ + public on(eventName: string, callback: (...args: any[]) => void): void { + if (!this.connection) { + console.warn('SignalR connection not initialized'); + return; + } + + this.connection.on(eventName, callback); + } + + /** + * Unsubscribe from an event + */ + public off(eventName: string, callback?: (...args: any[]) => void): void { + if (!this.connection) { + console.warn('SignalR connection not initialized'); + return; + } + + if (callback) { + this.connection.off(eventName, callback); + } else { + this.connection.off(eventName); + } + } + + /** + * Check if the connection is active + */ + public isConnected(): boolean { + return this.connection?.state === signalR.HubConnectionState.Connected; + } + + /** + * Invoke a hub method + */ + public async invoke(methodName: string, ...args: any[]): Promise { + if (!this.connection) { + throw new Error('SignalR connection not initialized'); + } + + return await this.connection.invoke(methodName, ...args); + } +} + +// Export a singleton instance +export const signalRService = new SignalRService(); + diff --git a/UI/src/types/obs.ts b/UI/src/types/obs.ts new file mode 100644 index 0000000..bfad9b5 --- /dev/null +++ b/UI/src/types/obs.ts @@ -0,0 +1,83 @@ +/** + * OBS connection status information + */ +export interface OBSConnectionStatus { + IsConnected: boolean; + ServerUrl?: string; + LastError?: string; + LastConnectionAttempt?: string; + ConnectedAt?: string; +} + +/** + * OBS scene information + */ +export interface Scene { + Name: string; + IsActive: boolean; + Index: number; +} + +/** + * Response from getting scenes + */ +export interface ScenesResponse { + CurrentScene: string; + Scenes: Scene[]; +} + +/** + * Request to switch scenes + */ +export interface SwitchSceneRequest { + SceneName: string; +} + +/** + * Streaming status information + */ +export interface StreamingStatus { + IsStreaming: boolean; + IsRecording: boolean; + StreamDuration: number; +} + +/** + * Scene item information + */ +export interface SceneItem { + SceneItemId: number; + SceneItemIndex: number; + SourceName: string; + SourceUuid: string; + SourceType: string; + SceneItemEnabled: boolean; +} + +/** + * Media input status information + */ +export interface MediaInputStatus { + MediaState: string; + MediaDuration: number | null; + MediaCursor: number | null; + RemainingTime: number | null; +} + +export interface SceneMediaStatus { + SceneName: string; + MediaInputName: string | null; + Status: MediaInputStatus | null; +} + +/** + * SignalR connection state + */ +export enum SignalRConnectionState { + Disconnected = 'Disconnected', + Connecting = 'Connecting', + Connected = 'Connected', + Reconnecting = 'Reconnecting', + Disconnecting = 'Disconnecting', +} + diff --git a/UI/tailwind.config.js b/UI/tailwind.config.js new file mode 100644 index 0000000..d37737f --- /dev/null +++ b/UI/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/UI/tsconfig.app.json b/UI/tsconfig.app.json new file mode 100644 index 0000000..e6e8706 --- /dev/null +++ b/UI/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/UI/tsconfig.json b/UI/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/UI/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/UI/tsconfig.node.json b/UI/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/UI/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/UI/vite.config.ts b/UI/vite.config.ts new file mode 100644 index 0000000..df3899a --- /dev/null +++ b/UI/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5080', + changeOrigin: true, + secure: false, + }, + '/hubs': { + target: 'http://localhost:5080', + changeOrigin: true, + secure: false, + ws: true, + }, + }, + }, +}) From c5bdb1370191660bf8aecd3c38b91921905241b9 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Mon, 13 Oct 2025 19:57:33 -0400 Subject: [PATCH 03/13] Adds OBS Virtual Camera control and volume meter Implements new endpoints for starting, stopping, and checking the status of the OBS Virtual Camera. Enhances audio monitoring by introducing real-time volume meter updates, allowing users to visualize audio levels from all active inputs. Introduces a new component to manage and display these volume levels, along with settings for displaying dB values and scales. Improves SignalR integration for efficient audio data streaming and enhances the user interface for better interaction. --- .gitignore | 2 + .../Controllers/OBSController.cs | 69 ++++ .../Services/OBSEventBroadcaster.cs | 26 ++ .../Interfaces/IOBSService.cs | 23 ++ .../Models/InputVolumeMeter.cs | 41 ++ .../Services/MediaStatusTracker.cs | 2 +- .../Services/OBSService.cs | 374 +++++++++++++++++- .../Services/OBSWebSocketClient.cs | 12 +- .../Entities/StreamSession.cs | 52 +++ UI/src/components/AudioMeters.tsx | 228 +++++++++++ UI/src/components/ControlsMenu.tsx | 43 ++ UI/src/components/Dashboard.tsx | 23 ++ UI/src/components/VideoPreview.tsx | 310 +++++++++++++++ UI/src/hooks/useAudioMeterSettings.ts | 50 +++ UI/src/services/api.service.ts | 22 ++ UI/src/services/signalr.service.ts | 1 - 16 files changed, 1263 insertions(+), 15 deletions(-) create mode 100644 API/ThriveStreamController.Core/Models/InputVolumeMeter.cs create mode 100644 API/ThriveStreamController.Data/Entities/StreamSession.cs create mode 100644 UI/src/components/AudioMeters.tsx create mode 100644 UI/src/components/VideoPreview.tsx create mode 100644 UI/src/hooks/useAudioMeterSettings.ts diff --git a/.gitignore b/.gitignore index d38ed8a..3271c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,3 +203,5 @@ lib-cov *.db-wal *.db-shm *.user + +*.http diff --git a/API/ThriveStreamController.API/Controllers/OBSController.cs b/API/ThriveStreamController.API/Controllers/OBSController.cs index 1b6322f..41a580d 100644 --- a/API/ThriveStreamController.API/Controllers/OBSController.cs +++ b/API/ThriveStreamController.API/Controllers/OBSController.cs @@ -365,6 +365,75 @@ public async Task> GetMediaInputStatus(string inp 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/Services/OBSEventBroadcaster.cs b/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs index fe6e167..fc10e28 100644 --- a/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs +++ b/API/ThriveStreamController.API/Services/OBSEventBroadcaster.cs @@ -38,6 +38,7 @@ public Task StartAsync(CancellationToken cancellationToken) _obsService.ConnectionStatusChanged += OnConnectionStatusChanged; _obsService.SceneChanged += OnSceneChanged; _obsService.StreamingStatusChanged += OnStreamingStatusChanged; + _obsService.VolumeMetersChanged += OnVolumeMetersChanged; _logger.LogInformation("OBS Event Broadcaster started"); return Task.CompletedTask; @@ -54,6 +55,7 @@ public Task StopAsync(CancellationToken cancellationToken) _obsService.ConnectionStatusChanged -= OnConnectionStatusChanged; _obsService.SceneChanged -= OnSceneChanged; _obsService.StreamingStatusChanged -= OnStreamingStatusChanged; + _obsService.VolumeMetersChanged -= OnVolumeMetersChanged; _logger.LogInformation("OBS Event Broadcaster stopped"); return Task.CompletedTask; @@ -106,5 +108,29 @@ private async void OnStreamingStatusChanged(object? sender, StreamingStatus stat _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.Core/Interfaces/IOBSService.cs b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs index 2bee5fd..c4ba3b2 100644 --- a/API/ThriveStreamController.Core/Interfaces/IOBSService.cs +++ b/API/ThriveStreamController.Core/Interfaces/IOBSService.cs @@ -28,6 +28,11 @@ public interface IOBSService /// event EventHandler? StreamingStatusChanged; + /// + /// Event raised when audio volume meters are updated (every 50ms). + /// + event EventHandler? VolumeMetersChanged; + /// /// Connects to the OBS WebSocket server. /// @@ -92,6 +97,24 @@ public interface IOBSService /// 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/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/Services/MediaStatusTracker.cs b/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs index d5b95af..cfce016 100644 --- a/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs +++ b/API/ThriveStreamController.Core/Services/MediaStatusTracker.cs @@ -83,7 +83,7 @@ private async Task ExecuteAsync(CancellationToken stoppingToken) } } - _logger.LogInformation("Media Status Tracker stopped"); + _logger.LogDebug("Media Status Tracker stopped"); } private async Task UpdateAllSceneMediaStatusAsync() diff --git a/API/ThriveStreamController.Core/Services/OBSService.cs b/API/ThriveStreamController.Core/Services/OBSService.cs index 12bf6c8..ba4ee99 100644 --- a/API/ThriveStreamController.Core/Services/OBSService.cs +++ b/API/ThriveStreamController.Core/Services/OBSService.cs @@ -18,6 +18,9 @@ public class OBSService : IOBSService, IDisposable 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. @@ -60,6 +63,11 @@ public OBSService(ILogger logger, ILogger client /// public event EventHandler? StreamingStatusChanged; + /// + /// Event raised when audio volume meters are updated (every 50ms). + /// + public event EventHandler? VolumeMetersChanged; + /// /// Connects to the OBS WebSocket server. /// @@ -193,11 +201,8 @@ public async Task> GetScenesAsync() return []; } - _logger.LogInformation("Fetching scenes from OBS..."); - // Send GetSceneList request var response = await _client.SendRequestAsync("GetSceneList"); - if (response == null) { _logger.LogWarning("GetSceneList returned null"); @@ -253,7 +258,7 @@ public async Task> GetScenesAsync() } } - _logger.LogInformation("Retrieved {Count} scenes from OBS", scenes.Count); + _logger.LogDebug("Retrieved {Count} scenes from OBS", scenes.Count); return scenes; } catch (Exception ex) @@ -459,6 +464,20 @@ public OBSConnectionStatus GetConnectionStatus() 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) @@ -470,11 +489,18 @@ private void OnDisconnected(object? sender, EventArgs e) _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); @@ -497,6 +523,97 @@ private void OnEventReceived(object? sender, JObject eventData) 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; } } @@ -515,7 +632,7 @@ public async Task> GetSceneItemsAsync(string sceneName) return new List(); } - _logger.LogInformation("Getting scene items for scene: {SceneName}", sceneName); + _logger.LogDebug("Getting scene items for scene: {SceneName}", sceneName); var requestData = new JObject { @@ -565,7 +682,7 @@ public async Task> GetSceneItemsAsync(string sceneName) sceneItems.Add(sceneItem); } - _logger.LogInformation("Found {Count} scene items", sceneItems.Count); + _logger.LogDebug("Found {Count} scene items", sceneItems.Count); return sceneItems; } catch (Exception ex) @@ -590,7 +707,7 @@ public async Task> GetSceneItemsAsync(string sceneName) return null; } - _logger.LogInformation("Getting media input status for: {InputName}", inputName); + _logger.LogDebug("Getting media input status for: {InputName}", inputName); var requestData = new JObject { @@ -631,7 +748,7 @@ public async Task> GetSceneItemsAsync(string sceneName) MediaCursor = responseData["mediaCursor"]?.Value() }; - _logger.LogInformation("Media status: State={State}, Duration={Duration}ms, Cursor={Cursor}ms", + _logger.LogDebug("Media status: State={State}, Duration={Duration}ms, Cursor={Cursor}ms", mediaStatus.MediaState, mediaStatus.MediaDuration, mediaStatus.MediaCursor); return mediaStatus; @@ -643,6 +760,247 @@ public async Task> GetSceneItemsAsync(string sceneName) } } + /// + /// 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(); diff --git a/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs b/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs index bff03b9..a41d75e 100644 --- a/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs +++ b/API/ThriveStreamController.Core/Services/OBSWebSocketClient.cs @@ -168,7 +168,7 @@ public async Task DisconnectAsync() } }; - _logger.LogInformation("Sending request: {RequestType} (ID: {RequestId})", requestType, requestId); + _logger.LogDebug("Sending request: {RequestType} (ID: {RequestId})", requestType, requestId); // Send request await SendMessageAsync(request); @@ -270,7 +270,7 @@ private async Task ProcessMessageAsync(string messageJson) var message = JObject.Parse(messageJson); var opCode = message["op"]?.Value() ?? -1; - _logger.LogInformation("Received message with OpCode: {OpCode}", opCode); + _logger.LogDebug("Received message with OpCode: {OpCode}", opCode); switch (opCode) { @@ -324,10 +324,12 @@ private async Task HandleHelloAsync(JObject? data) // 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"] = 2047 // Subscribe to all non-high-volume events + ["eventSubscriptions"] = eventSubscriptions }; // Handle authentication if required @@ -389,7 +391,7 @@ private void HandleEvent(JObject? data) if (data == null) return; var eventType = data["eventType"]?.Value(); - _logger.LogInformation("Received event: {EventType}", eventType); + _logger.LogDebug("Received event: {EventType}", eventType); EventReceived?.Invoke(this, data); } @@ -405,7 +407,7 @@ private void HandleRequestResponse(JObject? data) var result = requestStatus?["result"]?.Value() ?? false; var code = requestStatus?["code"]?.Value() ?? 0; - _logger.LogInformation("Received response for request {RequestId}: Result={Result}, Code={Code}", requestId, result, code); + _logger.LogDebug("Received response for request {RequestId}: Result={Result}, Code={Code}", requestId, result, code); // Find and complete the pending request TaskCompletionSource? tcs = null; 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/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/ControlsMenu.tsx b/UI/src/components/ControlsMenu.tsx index 536e340..0b7cf97 100644 --- a/UI/src/components/ControlsMenu.tsx +++ b/UI/src/components/ControlsMenu.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { Bars3Icon, ArrowPathIcon } from '@heroicons/react/24/solid'; +import type { AudioMeterSettings } from '@/hooks/useAudioMeterSettings'; interface ControlsMenuProps { isSignalRConnected: boolean; @@ -8,6 +9,8 @@ interface ControlsMenuProps { onConnect: () => void; onDisconnect: () => void; onRefreshScenes: () => void; + audioMeterSettings: AudioMeterSettings; + onAudioMeterSettingsChange: (updates: Partial) => void; } /** @@ -20,6 +23,8 @@ export const ControlsMenu: React.FC = ({ onConnect, onDisconnect, onRefreshScenes, + audioMeterSettings, + onAudioMeterSettingsChange, }) => { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -97,6 +102,44 @@ export const ControlsMenu: React.FC = ({ 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 index 4aedfe8..1f90077 100644 --- a/UI/src/components/Dashboard.tsx +++ b/UI/src/components/Dashboard.tsx @@ -2,8 +2,11 @@ import { useEffect, useState, useCallback } from 'react'; import { ConnectionStatus } from './ConnectionStatus'; import { SceneSwitcher } from './SceneSwitcher'; import { ControlsMenu } from './ControlsMenu'; +import { VideoPreview } from './VideoPreview'; +import { AudioMeters } from './AudioMeters'; 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'; @@ -13,6 +16,7 @@ import { SignalRConnectionState } from '@/types/obs'; 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); @@ -91,6 +95,8 @@ export const Dashboard: React.FC = () => { onConnect={handleManualConnect} onDisconnect={handleDisconnect} onRefreshScenes={handleRefreshScenes} + audioMeterSettings={audioMeterSettings} + onAudioMeterSettingsChange={updateAudioMeterSettings} />

@@ -123,7 +129,24 @@ export const Dashboard: React.FC = () => {

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

Program Out

+ +
+
+ {/* Audio Meters - Takes 1 column */} +
+ +
+
{/* Scene Switcher */}
diff --git a/UI/src/components/VideoPreview.tsx b/UI/src/components/VideoPreview.tsx new file mode 100644 index 0000000..7faa762 --- /dev/null +++ b/UI/src/components/VideoPreview.tsx @@ -0,0 +1,310 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { apiService } from '@/services/api.service'; + +interface VideoPreviewProps { + isOBSConnected: boolean; +} + +/** + * VideoPreview component that displays live video from OBS Virtual Camera + * Uses WebRTC getUserMedia to access the virtual camera device + */ +export const VideoPreview: React.FC = ({ isOBSConnected }) => { + const videoRef = useRef(null); + const streamRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isVirtualCamActive, setIsVirtualCamActive] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const containerRef = useRef(null); + + /** + * Start the OBS Virtual Camera + */ + const startVirtualCamera = useCallback(async () => { + try { + console.log('Starting OBS Virtual Camera...'); + await apiService.obsAPI.startVirtualCam(); + setIsVirtualCamActive(true); + setError(null); + // Wait a moment for the virtual camera to initialize + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (err) { + console.error('Failed to start virtual camera:', err); + setError('Failed to start OBS Virtual Camera'); + } + }, []); + + /** + * Access the OBS Virtual Camera using WebRTC + */ + const startVideoStream = useCallback(async () => { + if (!videoRef.current) return; + + setIsLoading(true); + setError(null); + + try { + // First, request camera permissions to enumerate devices properly + // We'll request any camera first, then switch to OBS Virtual Camera + let stream: MediaStream; + + try { + // Try to get OBS Virtual Camera directly by requesting video with constraints + stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1920 }, + height: { ideal: 1080 }, + aspectRatio: { ideal: 16/9 }, + }, + audio: false, + }); + + // Now enumerate devices to find OBS Virtual Camera + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter(device => device.kind === 'videoinput'); + + // Look for OBS Virtual Camera + const obsCamera = videoDevices.find(device => + device.label.toLowerCase().includes('obs') || + device.label.toLowerCase().includes('virtual') + ); + + if (obsCamera) { + console.log('Found OBS Virtual Camera:', obsCamera.label); + + // Stop the current stream + stream.getTracks().forEach(track => track.stop()); + + // Request the specific OBS Virtual Camera + stream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: { exact: obsCamera.deviceId }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + aspectRatio: { ideal: 16/9 }, + }, + audio: false, + }); + } else { + console.warn('OBS Virtual Camera not found in device list, using default camera'); + } + } catch (err) { + console.error('Error getting camera stream:', err); + throw err; + } + + streamRef.current = stream; + videoRef.current.srcObject = stream; + + console.log('Video stream started successfully'); + setError(null); + } catch (err) { + console.error('Error accessing virtual camera:', err); + + if (err instanceof Error) { + if (err.name === 'NotAllowedError') { + setError('Camera permission denied. Please allow camera access and refresh the page.'); + } else if (err.name === 'NotFoundError') { + setError('No camera found. Please start the OBS Virtual Camera.'); + } else if (err.name === 'NotReadableError') { + setError('Camera is already in use by another application.'); + } else { + setError(err.message); + } + } else { + setError('Failed to access camera'); + } + } finally { + setIsLoading(false); + } + }, []); + + /** + * Stop the video stream + */ + const stopVideoStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + } + if (videoRef.current) { + videoRef.current.srcObject = null; + } + }, []); + + /** + * Toggle fullscreen mode + */ + const toggleFullscreen = useCallback(async () => { + if (!containerRef.current) return; + + try { + if (!document.fullscreenElement) { + await containerRef.current.requestFullscreen(); + setIsFullscreen(true); + } else { + await document.exitFullscreen(); + setIsFullscreen(false); + } + } catch (err) { + console.error('Error toggling fullscreen:', err); + } + }, []); + + /** + * Handle fullscreen change events + */ + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, []); + + /** + * Initialize video stream when OBS connects + */ + useEffect(() => { + if (!isOBSConnected) { + stopVideoStream(); + setIsVirtualCamActive(false); + return; + } + + // Auto-start virtual camera and video stream when OBS connects + const initializePreview = async () => { + try { + // Check if virtual camera is already active + const status = await apiService.obsAPI.getVirtualCamStatus(); + + if (!status.outputActive) { + // Start virtual camera if not active + await startVirtualCamera(); + } else { + setIsVirtualCamActive(true); + } + + // Start video stream + await startVideoStream(); + } catch (err) { + console.error('Failed to initialize video preview:', err); + } + }; + + initializePreview(); + + // Cleanup on unmount or when OBS disconnects + return () => { + stopVideoStream(); + }; + }, [isOBSConnected, startVirtualCamera, startVideoStream, stopVideoStream]); + + /** + * Retry connection + */ + const handleRetry = useCallback(async () => { + setError(null); + + // Try to start virtual camera if not active + if (!isVirtualCamActive) { + await startVirtualCamera(); + } + + // Try to start video stream + await startVideoStream(); + }, [isVirtualCamActive, startVirtualCamera, startVideoStream]); + + if (!isOBSConnected) { + return ( +
+
+ + + +

OBS Not Connected

+

Connect to OBS to view live program output

+
+
+ ); + } + + return ( +
+ {/* Video Element */} +
+ ); +}; + diff --git a/UI/src/hooks/useAudioMeterSettings.ts b/UI/src/hooks/useAudioMeterSettings.ts new file mode 100644 index 0000000..45ccd4d --- /dev/null +++ b/UI/src/hooks/useAudioMeterSettings.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from 'react'; + +export interface AudioMeterSettings { + showDbValues: boolean; + showDbScale: boolean; +} + +const STORAGE_KEY = 'audioMeterSettings'; + +const DEFAULT_SETTINGS: AudioMeterSettings = { + showDbValues: true, + showDbScale: false, +}; + +/** + * Custom hook to manage audio meter settings in localStorage + */ +export const useAudioMeterSettings = () => { + const [settings, setSettings] = useState(() => { + // Load settings from localStorage on initial render + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }; + } + } catch (error) { + console.error('Failed to load audio meter settings from localStorage:', error); + } + return DEFAULT_SETTINGS; + }); + + // Save settings to localStorage whenever they change + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch (error) { + console.error('Failed to save audio meter settings to localStorage:', error); + } + }, [settings]); + + const updateSettings = (updates: Partial) => { + setSettings((prev) => ({ ...prev, ...updates })); + }; + + return { + settings, + updateSettings, + }; +}; + diff --git a/UI/src/services/api.service.ts b/UI/src/services/api.service.ts index 270d4c8..a29ec6b 100644 --- a/UI/src/services/api.service.ts +++ b/UI/src/services/api.service.ts @@ -106,6 +106,28 @@ class ApiService { const response = await this.axiosInstance.get(`/OBS/media/${encodeURIComponent(inputName)}/status`); return response.data; }, + + /** + * Get virtual camera status + */ + getVirtualCamStatus: async (): Promise<{ outputActive: boolean }> => { + const response = await this.axiosInstance.get<{ outputActive: boolean }>('/OBS/virtualcam/status'); + return response.data; + }, + + /** + * Start virtual camera + */ + startVirtualCam: async (): Promise => { + await this.axiosInstance.post('/OBS/virtualcam/start'); + }, + + /** + * Stop virtual camera + */ + stopVirtualCam: async (): Promise => { + await this.axiosInstance.post('/OBS/virtualcam/stop'); + }, }; } diff --git a/UI/src/services/signalr.service.ts b/UI/src/services/signalr.service.ts index 5ab267c..56a3ee4 100644 --- a/UI/src/services/signalr.service.ts +++ b/UI/src/services/signalr.service.ts @@ -96,7 +96,6 @@ class SignalRService { return; } - console.log('[SignalR] Subscribing to ConnectionStatusChanged event'); this.connection.on('ConnectionStatusChanged', (status: OBSConnectionStatus) => { console.log('[SignalR] Received ConnectionStatusChanged event:', status); callback(status); From 99c245a94f91bbb6a893243ee2bfa8ae077d3cd3 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Fri, 9 Jan 2026 11:46:21 -0500 Subject: [PATCH 04/13] feat: Add YouTube Live streaming integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add YouTube OAuth authentication (YouTubeAuthService, YouTubeAuthController) - Add YouTube Live broadcast management (YouTubeLiveService, YouTubeLiveController) - Create broadcasts with custom title/description - Persistent stream key support (OBS config never changes) - Broadcast lifecycle: create → bind → testing → live → complete - Thumbnail upload support - Get broadcast defaults from previous streams - Add credential encryption for secure token storage - Add StreamingControls UI component for volunteer-friendly operation - Start Stream button (creates YT broadcast + starts OBS) - Open Facebook button (opens Live Producer URL) - End Stream button (stops OBS + ends YT broadcast) - Real-time status indicators - Update Dashboard to include StreamingControls panel --- .../Controllers/YouTubeAuthController.cs | 173 ++++++ .../Controllers/YouTubeLiveController.cs | 306 +++++++++++ API/ThriveStreamController.API/Program.cs | 8 + .../ICredentialEncryptionService.cs | 23 + .../Interfaces/IYouTubeAuthService.cs | 59 ++ .../Interfaces/IYouTubeLiveService.cs | 115 ++++ .../Models/YouTubeBroadcastInfo.cs | 159 ++++++ .../Models/YouTubeChannelInfo.cs | 59 ++ .../Models/YouTubeConfiguration.cs | 76 +++ .../Services/CredentialEncryptionService.cs | 89 +++ .../Services/YouTubeAuthService.cs | 312 +++++++++++ .../Services/YouTubeLiveService.cs | 506 ++++++++++++++++++ .../ThriveStreamController.Core.csproj | 2 + .../ApplicationDbContext.cs | 10 + .../Entities/PersistentStreamConfig.cs | 64 +++ .../Entities/PlatformCredential.cs | 54 ++ UI/src/components/Dashboard.tsx | 60 ++- UI/src/components/StreamingControls.tsx | 312 +++++++++++ UI/src/services/api.service.ts | 204 +++++++ UI/src/services/signalr.service.ts | 2 +- UI/src/types/obs.ts | 16 +- 21 files changed, 2589 insertions(+), 20 deletions(-) create mode 100644 API/ThriveStreamController.API/Controllers/YouTubeAuthController.cs create mode 100644 API/ThriveStreamController.API/Controllers/YouTubeLiveController.cs create mode 100644 API/ThriveStreamController.Core/Interfaces/ICredentialEncryptionService.cs create mode 100644 API/ThriveStreamController.Core/Interfaces/IYouTubeAuthService.cs create mode 100644 API/ThriveStreamController.Core/Interfaces/IYouTubeLiveService.cs create mode 100644 API/ThriveStreamController.Core/Models/YouTubeBroadcastInfo.cs create mode 100644 API/ThriveStreamController.Core/Models/YouTubeChannelInfo.cs create mode 100644 API/ThriveStreamController.Core/Models/YouTubeConfiguration.cs create mode 100644 API/ThriveStreamController.Core/Services/CredentialEncryptionService.cs create mode 100644 API/ThriveStreamController.Core/Services/YouTubeAuthService.cs create mode 100644 API/ThriveStreamController.Core/Services/YouTubeLiveService.cs create mode 100644 API/ThriveStreamController.Data/Entities/PersistentStreamConfig.cs create mode 100644 API/ThriveStreamController.Data/Entities/PlatformCredential.cs create mode 100644 UI/src/components/StreamingControls.tsx 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/Program.cs b/API/ThriveStreamController.API/Program.cs index 0cefcc8..8851b6e 100644 --- a/API/ThriveStreamController.API/Program.cs +++ b/API/ThriveStreamController.API/Program.cs @@ -3,6 +3,7 @@ using ThriveStreamController.API.Hubs; using ThriveStreamController.API.Services; using ThriveStreamController.Core.Interfaces; +using ThriveStreamController.Core.Models; using ThriveStreamController.Core.Services; using ThriveStreamController.Data; @@ -58,7 +59,14 @@ 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(); 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/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/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/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/ThriveStreamController.Core.csproj b/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj index 32db17c..1ede096 100644 --- a/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj +++ b/API/ThriveStreamController.Core/ThriveStreamController.Core.csproj @@ -5,6 +5,8 @@ + + diff --git a/API/ThriveStreamController.Data/ApplicationDbContext.cs b/API/ThriveStreamController.Data/ApplicationDbContext.cs index ee2d747..058fee3 100644 --- a/API/ThriveStreamController.Data/ApplicationDbContext.cs +++ b/API/ThriveStreamController.Data/ApplicationDbContext.cs @@ -23,6 +23,16 @@ public ApplicationDbContext(DbContextOptions options) /// 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. /// 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/UI/src/components/Dashboard.tsx b/UI/src/components/Dashboard.tsx index 1f90077..df3bba3 100644 --- a/UI/src/components/Dashboard.tsx +++ b/UI/src/components/Dashboard.tsx @@ -1,9 +1,11 @@ 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'; @@ -20,6 +22,21 @@ export const Dashboard: React.FC = () => { 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 @@ -103,14 +120,22 @@ export const Dashboard: React.FC = () => { Thrive Stream Controller

- Manage your OBS Studio livestreams with ease + Manage your livestreams with ease

- +
+ + ⚙️ Configure Accounts + + +
{/* Auto-connect error message */} @@ -148,13 +173,24 @@ export const Dashboard: React.FC = () => { - {/* Scene Switcher */} -
- + {/* Streaming Controls and Scene Switcher Row */} +
+ {/* Streaming Controls - Takes 1 column */} +
+ +
+ + {/* Scene Switcher - Takes 2 columns */} +
+ +
{/* Footer */} 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" + /> +
+
+ +