diff --git a/.circleci/config.yml b/.circleci/config.yml index 0390907..01d142b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -165,7 +165,7 @@ jobs: - run: name: CMake Configure command: | - CMAKE_ARGS="-DONESEVENLIVE_API_URL=\"$ONESEVENLIVE_API_URL\" -DCMAKE_PROJECT_VERSION=\"$VERSION\"" + CMAKE_ARGS="-DYOUTUBE_API_CLIENT_ID=\"$YOUTUBE_API_CLIENT_ID\" -DYOUTUBE_API_CLIENT_SECRET=\"$YOUTUBE_API_CLIENT_SECRET\" -DTWITCH_API_CLIENT_ID=\"$TWITCH_API_CLIENT_ID\" -DONESEVENLIVE_API_URL=\"$ONESEVENLIVE_API_URL\" -DCMAKE_PROJECT_VERSION=\"$VERSION\"" # Add architecture parameter if specified if [ -n "<< parameters.cmake_arch >>" ]; then @@ -424,7 +424,7 @@ jobs: - run: name: CMake Configure command: | - cmake --preset windows-x64 -DONESEVENLIVE_API_URL="$env:ONESEVENLIVE_API_URL" -DCMAKE_PROJECT_VERSION="$env:VERSION" + cmake --preset windows-x64 -DYOUTUBE_API_CLIENT_ID="$env:YOUTUBE_API_CLIENT_ID" -DYOUTUBE_API_CLIENT_SECRET="$env:YOUTUBE_API_CLIENT_SECRET" -DTWITCH_API_CLIENT_ID="$env:TWITCH_API_CLIENT_ID" -DONESEVENLIVE_API_URL="$env:ONESEVENLIVE_API_URL" -DCMAKE_PROJECT_VERSION="$env:VERSION" # Build the project - run: @@ -467,10 +467,13 @@ jobs: # Move the generated installer from output directory to root directory for artifact upload $installerPattern = "17liveOBSPlugin-windows-v*.exe" + $zipPattern = "17liveOBSPlugin-windows-v*-non-installer.zip" if (Test-Path "output") { $installerFile = Get-ChildItem -Path "output" -Name $installerPattern | Select-Object -First 1 + $zipFile = Get-ChildItem -Path "output" -Name $zipPattern | Select-Object -First 1 } else { $installerFile = $null + $zipFile = $null } if ($installerFile) { Move-Item "output/$installerFile" "../../$installerFile" @@ -483,6 +486,16 @@ jobs: Write-Error "Installer file not found in output directory after build" exit 1 } + if ($zipFile) { + Move-Item "output/$zipFile" "../../$zipFile" + echo "NON_INSTALLER_NAME=$zipFile" | Out-File -FilePath $env:BASH_ENV -Encoding utf8 -Append + [Environment]::SetEnvironmentVariable("NON_INSTALLER_NAME", $zipFile, "Machine") + $env:NON_INSTALLER_NAME = $zipFile + Write-Host "Non-installer zip built successfully: $zipFile" + } else { + Write-Error "Non-installer zip file not found in output directory after build" + exit 1 + } @@ -507,6 +520,12 @@ jobs: $artifactPath = Join-Path "artifacts" $expectedName Copy-Item $env:INSTALLER_NAME $artifactPath -Force } + if ($env:NON_INSTALLER_NAME -and (Test-Path $env:NON_INSTALLER_NAME)) { + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + # Keep the exact filename produced (already contains version and -non-installer suffix) + $zipArtifactPath = Join-Path "artifacts" $env:NON_INSTALLER_NAME + Copy-Item $env:NON_INSTALLER_NAME $zipArtifactPath -Force + } - store_artifacts: path: artifacts destination: packages @@ -523,21 +542,32 @@ workflows: # Tag-based build workflow - supports both production and stage builds tag-build: jobs: + # - build-macos: + # name: build-macos-apple-silicon + # arch_name: "macAppleSilicon" + # cmake_arch: "" + # filters: + # tags: + # only: /^v\d+\.\d+\.\d+(\.\d+)?(-stage)?$/ + # branches: + # ignore: /.*/ + # context: + # - macos-signing + # - build-macos: + # name: build-macos-intel + # arch_name: "macIntel" + # cmake_arch: "x86_64" + # filters: + # tags: + # only: /^v\d+\.\d+\.\d+(\.\d+)?(-stage)?$/ + # branches: + # ignore: /.*/ + # context: + # - macos-signing - build-macos: - name: build-macos-apple-silicon - arch_name: "macAppleSilicon" - cmake_arch: "" - filters: - tags: - only: /^v\d+\.\d+\.\d+(\.\d+)?(-stage)?$/ - branches: - ignore: /.*/ - context: - - macos-signing - - build-macos: - name: build-macos-intel - arch_name: "macIntel" - cmake_arch: "x86_64" + name: build-macos-universal + arch_name: "mac-universal" + # cmake_arch: "arm64;x86_64" filters: tags: only: /^v\d+\.\d+\.\d+(\.\d+)?(-stage)?$/ @@ -559,18 +589,26 @@ workflows: jobs: - hold: type: approval + # - build-macos: + # name: build-macos-apple-silicon + # arch_name: "macAppleSilicon" + # cmake_arch: "" + # requires: + # - hold + # context: + # - macos-signing + # - build-macos: + # name: build-macos-intel + # arch_name: "macIntel" + # cmake_arch: "x86_64" + # requires: + # - hold + # context: + # - macos-signing - build-macos: - name: build-macos-apple-silicon - arch_name: "macAppleSilicon" - cmake_arch: "" - requires: - - hold - context: - - macos-signing - - build-macos: - name: build-macos-intel - arch_name: "macIntel" - cmake_arch: "x86_64" + name: build-macos-universal + arch_name: "mac-universal" + # cmake_arch: "arm64;x86_64" requires: - hold context: @@ -579,4 +617,4 @@ workflows: requires: - hold context: - - windows-signing \ No newline at end of file + - windows-signing diff --git a/.github/workflows/build-macos-apple-silicon.yml b/.github/workflows/build-macos-apple-silicon.yml index 42f7382..73f855e 100644 --- a/.github/workflows/build-macos-apple-silicon.yml +++ b/.github/workflows/build-macos-apple-silicon.yml @@ -1,25 +1,27 @@ name: Build OBS 17Live Plugin - macOS Apple Silicon -on: - # push: - # tags: - # - 'v*.*.*' - # - 'v*.*.*-stage' - workflow_dispatch: - inputs: - environment: - description: "Build environment" - required: true - default: stage - type: choice - options: - - stage - - production - version: - description: "Version number (e.g., 1.0.0)" - required: false - default: "0.0.0" - type: string +# on: +# push: +# tags: +# - 'v*.*.*' +# - 'v*.*.*.*' +# - 'v*.*.*-stage' +# - 'v*.*.*.*-stage' +# workflow_dispatch: +# inputs: +# environment: +# description: "Build environment" +# required: true +# default: stage +# type: choice +# options: +# - stage +# - production +# version: +# description: "Version number (e.g., 1.0.0 or 1.1.4.1)" +# required: false +# default: "0.0.0" +# type: string jobs: build-mac-arm64: diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml index 194916d..5533e42 100644 --- a/.github/workflows/build-macos-intel.yml +++ b/.github/workflows/build-macos-intel.yml @@ -1,25 +1,27 @@ name: Build OBS 17Live Plugin - macOS Intel -on: - # push: - # tags: - # - 'v*.*.*' - # - 'v*.*.*-stage' - workflow_dispatch: - inputs: - environment: - description: "Build environment" - required: true - default: stage - type: choice - options: - - stage - - production - version: - description: "Version number (e.g., 1.0.0)" - required: false - default: "0.0.0" - type: string +# on: +# push: +# tags: +# - 'v*.*.*' +# - 'v*.*.*.*' +# - 'v*.*.*-stage' +# - 'v*.*.*.*-stage' +# workflow_dispatch: +# inputs: +# environment: +# description: "Build environment" +# required: true +# default: stage +# type: choice +# options: +# - stage +# - production +# version: +# description: "Version number (e.g., 1.0.0 or 1.1.4.1)" +# required: false +# default: "0.0.0" +# type: string jobs: build-mac-x64: diff --git a/.github/workflows/build-macos-reusable.yml b/.github/workflows/build-macos-reusable.yml index 33bd057..23454da 100644 --- a/.github/workflows/build-macos-reusable.yml +++ b/.github/workflows/build-macos-reusable.yml @@ -62,8 +62,9 @@ jobs: with: path: | web/ably_chat/node_modules + web/ably_client/node_modules ~/.npm - key: ${{ runner.os }}-npm-${{ hashFiles('web/ably_chat/package-lock.json') }} + key: ${{ runner.os }}-npm-${{ hashFiles('web/ably_chat/package-lock.json', 'web/ably_client/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- @@ -87,6 +88,14 @@ jobs: fi npm run build + - name: Install and Build Ably Client + working-directory: web/ably_client + run: | + if [ "${{ steps.npm-cache.outputs.cache-hit }}" != "true" ]; then + npm install + fi + npm run build + - name: Set ENV and Version run: | # Remove v prefix from VERSION if present @@ -127,8 +136,12 @@ jobs: fi - name: CMake Configure + env: + YOUTUBE_API_CLIENT_ID: ${{ vars.YOUTUBE_API_CLIENT_ID }} + YOUTUBE_API_CLIENT_SECRET: ${{ vars.YOUTUBE_API_CLIENT_SECRET }} + TWITCH_API_CLIENT_ID: ${{ vars.TWITCH_API_CLIENT_ID }} run: | - CMAKE_ARGS="-DONESEVENLIVE_API_URL=\"$ONESEVENLIVE_API_URL\" -DCMAKE_PROJECT_VERSION=\"$VERSION\"" + CMAKE_ARGS="-DYOUTUBE_API_CLIENT_ID=\"$YOUTUBE_API_CLIENT_ID\" -DYOUTUBE_API_CLIENT_SECRET=\"$YOUTUBE_API_CLIENT_SECRET\" -DTWITCH_API_CLIENT_ID=\"$TWITCH_API_CLIENT_ID\" -DONESEVENLIVE_API_URL=\"$ONESEVENLIVE_API_URL\" -DCMAKE_PROJECT_VERSION=\"$VERSION\"" # Add architecture parameter if specified if [ -n "${{ inputs.cmake_arch }}" ]; then @@ -168,4 +181,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ inputs.artifact_name }} - path: ${{ env.PKG_NAME }} \ No newline at end of file + path: ${{ env.PKG_NAME }} diff --git a/.github/workflows/build-macos-universal.yml b/.github/workflows/build-macos-universal.yml new file mode 100644 index 0000000..7a3a506 --- /dev/null +++ b/.github/workflows/build-macos-universal.yml @@ -0,0 +1,35 @@ +name: Build OBS 17Live Plugin - macOS Universal + +on: + push: + tags: + - 'v*.*.*' + - 'v*.*.*.*' + - 'v*.*.*-stage' + - 'v*.*.*.*-stage' + workflow_dispatch: + inputs: + environment: + description: "Build environment" + required: true + default: stage + type: choice + options: + - stage + - production + version: + description: "Version number (e.g., 1.0.0 or 1.1.4.1)" + required: false + default: "0.0.0" + type: string + +jobs: + build-mac-universal: + uses: ./.github/workflows/build-macos-reusable.yml + with: + arch_name: "mac-universal" + # cmake_arch: "arm64;x86_64" + artifact_name: "macOS-Universal-PKG" + use_sudo: false + environment: ${{ github.event_name == 'push' && (contains(github.ref_name, '-stage') && 'stage' || 'production') || github.event.inputs.environment || 'stage' }} + version: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) && github.ref_name || github.event.inputs.version || '0.0.0' }} diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 311e963..52b4a3d 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -1,10 +1,12 @@ name: Build OBS 17Live Plugin - Windows on: - # push: - # tags: - # - 'v*.*.*' # Production builds (e.g., v1.0.0) - # - 'v*.*.*-stage' # Stage builds (e.g., v1.0.0-stage) + push: + tags: + - 'v*.*.*' + - 'v*.*.*.*' + - 'v*.*.*-stage' + - 'v*.*.*.*-stage' workflow_dispatch: inputs: environment: @@ -16,7 +18,7 @@ on: - stage - production version: - description: "Version number (e.g., 1.0.0)" + description: "Version number (e.g., 1.0.0 or 1.1.4.1)" required: false default: "0.0.0" type: string @@ -60,8 +62,9 @@ jobs: with: path: | web/ably_chat/node_modules + web/ably_client/node_modules ~\AppData\Roaming\npm-cache - key: ${{ runner.os }}-npm-${{ hashFiles('web/ably_chat/package-lock.json') }} + key: ${{ runner.os }}-npm-${{ hashFiles('web/ably_chat/package-lock.json', 'web/ably_client/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- @@ -86,6 +89,13 @@ jobs: Write-Host "Skipping npm install (dependencies restored from cache)" } npm run build + + - name: Install and Build Ably Client + shell: pwsh + run: | + cd web/ably_client + if ("${{ steps.npm-cache.outputs.cache-hit }}" -ne "true") { npm install } + npm run build - name: Set ENV and Version shell: pwsh @@ -136,10 +146,13 @@ jobs: - name: CMake Configure shell: pwsh - run: | - cmake --preset windows-x64 -DONESEVENLIVE_API_URL="$env:ONESEVENLIVE_API_URL" -DCMAKE_PROJECT_VERSION="$env:VERSION" env: + YOUTUBE_API_CLIENT_ID: ${{ vars.YOUTUBE_API_CLIENT_ID }} + YOUTUBE_API_CLIENT_SECRET: ${{ vars.YOUTUBE_API_CLIENT_SECRET }} + TWITCH_API_CLIENT_ID: ${{ vars.TWITCH_API_CLIENT_ID }} ONESEVENLIVE_API_URL: ${{ vars.ONESEVENLIVE_API_URL }} + run: | + cmake --preset windows-x64 -DYOUTUBE_API_CLIENT_ID="$env:YOUTUBE_API_CLIENT_ID" -DYOUTUBE_API_CLIENT_SECRET="$env:YOUTUBE_API_CLIENT_SECRET" -DTWITCH_API_CLIENT_ID="$env:TWITCH_API_CLIENT_ID" -DONESEVENLIVE_API_URL="$env:ONESEVENLIVE_API_URL" -DCMAKE_PROJECT_VERSION="$env:VERSION" - name: Build Project shell: pwsh diff --git a/.gitignore b/.gitignore index 87740f0..1fff2c3 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ __pycache__/ .ipynb_checkpoints/ .trae/ .obspluginrc.json + +output/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 1dc8848..ca4767d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,7 @@ project(${_name} VERSION ${_version}) option(ENABLE_FRONTEND_API "Use obs-frontend-api for UI functionality" OFF) option(ENABLE_QT "Use Qt functionality" OFF) +option(USE_STATIC_MSVC_RUNTIME "Use static MSVC runtime library (/MT) instead of dynamic (/MD)" ON) include(compilerconfig) include(defaults) @@ -13,27 +14,45 @@ include(helpers) add_library(${CMAKE_PROJECT_NAME} MODULE) -# Set the runtime library on Windows to static linking (/MT) to match the CEF library -if(OS_WINDOWS) - set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") - target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE - $<$:/MT> - $<$:/MT> - $<$:/MTd> - ) -endif() +# Set the runtime library on Windows based on the option +# if(OS_WINDOWS) +# # obs-studio official strategy: use static runtime (/MT) for CEF compatibility +# # Provide stdio function compatibility for MbedTLS through legacy_stdio_definitions.lib +# if(USE_STATIC_MSVC_RUNTIME) +# message(STATUS "Using static MSVC runtime for Windows build") +# set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +# else() +# message(STATUS "Using dynamic MSVC runtime for Windows build") +# set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL$<$:Debug>") +# endif() +# # Ensure all targets use consistent runtime library settings +# set(CMAKE_MSVC_RUNTIME_LIBRARY_DEFAULT ${CMAKE_MSVC_RUNTIME_LIBRARY}) +# message(STATUS "CMAKE_MSVC_RUNTIME_LIBRARY set to: ${CMAKE_MSVC_RUNTIME_LIBRARY}") +# endif() find_package(libobs REQUIRED) target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE OBS::libobs) find_package(CURL REQUIRED) target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE CURL::libcurl) +# zlib for gzip inflate used in Ably payload decoding +find_package(ZLIB REQUIRED) +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE ZLIB::ZLIB) + if(ENABLE_FRONTEND_API) find_package(obs-frontend-api REQUIRED) target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE OBS::obs-frontend-api) endif() +if(OS_WINDOWS) + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE + ws2_32 + bcrypt + winhttp + ) +endif() + if(ENABLE_QT) # Add our custom cmake modules to the front of the module path # This ensures our custom FindWrapOpenGL.cmake is used instead of Qt6's version @@ -50,18 +69,14 @@ if(ENABLE_QT) ) endif() -# Add CEF support -# CEF_ROOT_DIR should already be set in defaults.cmake -> buildspec.cmake -set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") - -if(CEF_ROOT_DIR) - set(CEF_ROOT "${CEF_ROOT_DIR}") - message(STATUS "Using CEF_ROOT: ${CEF_ROOT}") -else() - message(FATAL_ERROR "CEF_ROOT_DIR not set. Dependencies may not have been processed correctly.") +# obs-studio official strategy: prefer pre-built static libraries to avoid runtime conflicts +if(NOT OS_WINDOWS) + find_package(MbedTLS REQUIRED) endif() -find_package(CEF REQUIRED) +# Add LibDataChannel support from obs-deps +find_package(LibDataChannel REQUIRED) +target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE LibDataChannel::LibDataChannel) # Add nlohmann json include path from obs-deps find_path(NLOHMANN_JSON_INCLUDE_DIR @@ -83,53 +98,139 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/17live ${CMAKE_CURRENT_SOURCE_DIR}/deps ${NLOHMANN_JSON_INCLUDE_DIR} - ${CEF_ROOT_DIR} # This might need adjustment based on actual CEF include path + ${CEF_INCLUDE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/src # For finding the generated plugin-support.c ) +# Add websocketpp include directory from obs-deps +target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/deps/cpp-httplib + ${CMAKE_PREFIX_PATH}/include # For websocketpp from obs-deps +) + # Link CEF libraries (nlohmann json is header-only) +find_package(Threads REQUIRED) target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE - CEF::Library - CEF::Wrapper - Threads::Threads # For cpp-httplib + Threads::Threads # For cpp-httplib and websocketpp ) -# Add cpp-httplib include directory (though it might already be covered by 'deps') -target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/deps/cpp-httplib +# Platform-specific MbedTLS linking +if(NOT OS_WINDOWS) + # # obs-studio official strategy: use static runtime (/MT) for CEF compatibility + # # Provide stdio function compatibility for MbedTLS through legacy_stdio_definitions.lib + # target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE + # ws2_32 + # bcrypt + # winhttp + # ) + + # # Windows-specific definitions for stdio compatibility + # target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE + # _CRT_DECLARE_NONSTDC_NAMES=1 # For stdio functions like setbuf + # _WIN32_WINNT=0x0A00 # Windows 10 and later + # ) + + # # obs-studio strategy: use static runtime, provide stdio compatibility through legacy libraries + # # Provide stdio function compatibility for MbedTLS (setbuf, ferror, remove, etc.) + + # # CEF wrapper library compatibility handling - use static runtime + # set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES + # MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + # LINK_FLAGS "/WX:NO" + # ) + # else() + # macOS/Linux: Standard MbedTLS linking + target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE + MbedTLS::mbedcrypto + MbedTLS::mbedtls + MbedTLS::mbedx509 + ) +endif() + +# Suppress newline-at-EOF warning on headers included by this target +target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE + $<$,$>:-Wno-newline-eof -Wno-error=newline-eof> + $<$:-Wno-eof-newline -Wno-error=eof-newline> ) +# Define websocketpp to use C++11 STL features +# target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE +# _WEBSOCKETPP_CPP11_STL_ +# ) + # Configure plugin-support.c file configure_file(src/plugin-support.c.in src/plugin-support.c @ONLY) -target_sources(${CMAKE_PROJECT_NAME} PRIVATE src/plugin-main.cpp - src/plugin-support.c - src/17live/OneSevenLiveCoreManager.cpp - src/17live/OneSevenLiveConfigManager.cpp - src/17live/api/OneSevenLiveModels.cpp - src/17live/utility/RemoteTextThread.cpp - src/17live/utility/Common.cpp - src/17live/utility/Meta.cpp - src/17live/utility/DownloadWorker.cpp - src/17live/utility/NetworkDiagnostics.cpp - src/17live/utility/CustomCalendarWidget.cpp +target_sources(${CMAKE_PROJECT_NAME} PRIVATE + resources.qrc + src/17live/api/OneSevenLiveAblyChatClient.cpp src/17live/api/OneSevenLiveApiWrappers.cpp - src/17live/CefDummy.cpp - src/17live/QCefView.cpp - src/17live/SimpleCefClient.cpp - src/17live/OneSevenLiveMenuManager.cpp - src/17live/OneSevenLiveLoginDialog.cpp - src/17live/OneSevenLiveCustomEventDialog.cpp - src/17live/OneSevenLiveStreamingDock.cpp - src/17live/OneSevenLiveStreamListItem.cpp - src/17live/OneSevenLiveStreamListDock.cpp - src/17live/OneSevenLiveRockZoneDock.cpp - src/17live/OneSevenLiveUserDialog.cpp - src/17live/OneSevenLiveRockViewerItem.cpp + src/17live/api/OneSevenLiveModels.cpp src/17live/api/OneSevenLiveUtility.cpp + src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.cpp + src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.cpp + src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.cpp + src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.cpp + src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.cpp + src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.cpp + src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.cpp + src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.cpp + src/17live/chat/OneSevenLiveChatWidget.cpp + src/17live/OneSevenLiveConfigManager.cpp + src/17live/OneSevenLiveCoreManager.cpp + src/17live/OneSevenLiveCustomEventDialog.cpp src/17live/OneSevenLiveHttpServer.cpp + src/17live/OneSevenLiveLoginDialog.cpp + src/17live/OneSevenLiveMenuManager.cpp src/17live/OneSevenLiveUpdateManager.cpp - resources.qrc) + src/17live/rockzone/OneSevenLiveRockViewerItem.cpp + src/17live/rockzone/OneSevenLiveRockZoneDock.cpp + src/17live/rockzone/OneSevenLiveUserDialog.cpp + src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.cpp + src/17live/streaming/OneSevenLiveStreamManager.cpp + src/17live/streaming/OneSevenLiveStreamingDock.cpp + src/17live/streamlist/OneSevenLiveStreamListDock.cpp + src/17live/streamlist/OneSevenLiveStreamListItem.cpp + src/17live/twitch/OneSevenLiveTwitchAuth.cpp + src/17live/twitch/OneSevenLiveTwitchChatClient.cpp + src/17live/twitch/OneSevenLiveTwitchClient.cpp + src/17live/preview/OneSevenLivePreviewConfigLoader.cpp + src/17live/preview/OneSevenLivePreviewDock.cpp + src/17live/preview/OneSevenLivePreviewWidget.cpp + src/17live/ui/OneSevenLiveAuthDialog.cpp + src/17live/ui/OneSevenLiveLineEditWithEye.cpp + src/17live/ui/OneSevenLivePropertiesWidget.cpp + src/17live/ui/OneSevenLivePropertyWidget.cpp + src/17live/utility/Common.cpp + src/17live/utility/CustomCalendarWidget.cpp + src/17live/utility/DownloadWorker.cpp + src/17live/utility/Meta.cpp + src/17live/utility/NetworkDiagnostics.cpp + src/17live/utility/RemoteTextThread.cpp + src/17live/chat/OneSevenLiveChatMessageHandler.cpp + src/17live/websocket/OneSevenLiveWebsocketClient.cpp + src/17live/websocket/OneSevenLiveWebsocketServer.cpp + src/17live/websocket/WebsocketUtils.cpp + src/17live/youtube/OneSevenLiveYouTubeAuth.cpp + src/17live/youtube/OneSevenLiveYouTubeChatClient.cpp + src/17live/youtube/OneSevenLiveYouTubeClient.cpp + src/diag/DiagnosticsCollectorFactory.cpp + src/diag/DiagnosticsCollectorBase.cpp + src/diag/PrivacyFilter.cpp + src/diag/ui/DiagnosticsDialog.cpp + src/plugin-main.cpp + src/plugin-support.c +) + +if(OS_MACOS) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE + src/diag/DiagnosticsCollectorMacOS.cpp + ) +elseif(OS_WINDOWS) + target_sources(${CMAKE_PROJECT_NAME} PRIVATE + src/diag/DiagnosticsCollectorWindows.cpp + ) +endif() if(MSVC) target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE /wd4996) @@ -140,11 +241,27 @@ endif() set_target_properties_plugin(${CMAKE_PROJECT_NAME} PROPERTIES OUTPUT_NAME ${_name}) # Windows-specific CEF configuration -if(OS_WINDOWS) - # Add Windows specific defines - target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE - NOMINMAX - WIN32_LEAN_AND_MEAN - _CRT_SECURE_NO_WARNINGS - ) -endif() +# if(OS_WINDOWS) +# # Add Windows specific defines +# target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE +# NOMINMAX +# WIN32_LEAN_AND_MEAN +# _CRT_SECURE_NO_WARNINGS +# _CRT_DECLARE_NONSTDC_NAMES=1 # Ensure stdio functions like setbuf are properly declared +# ) + +# # Disable treat warnings as errors to allow LNK4098 warnings +# target_link_options(${CMAKE_PROJECT_NAME} PRIVATE +# /WX:NO # Don't treat linker warnings as errors +# /ignore:4098 # Ignore default library conflicts (mixed runtime libraries) +# /ignore:4286 # Ignore symbol already defined warnings +# ) + +# # obs-studio strategy: use static runtime to ensure CEF compatibility +# # Provide stdio function compatibility for MbedTLS through legacy_stdio_definitions.lib + +# # Provide stdio function compatibility layer (resolve MbedTLS setbuf and other function requirements) + +# # Comprehensive runtime library conflict resolution +# message(STATUS "Windows static runtime configuration with SChannel via WinHTTP") +# endif() diff --git a/CMakePresets.json b/CMakePresets.json index 339c3f8..39aabb6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -18,7 +18,9 @@ "name": "macos", "displayName": "macOS Universal", "description": "Build for macOS 11.0+ (Universal binary)", - "inherits": ["template"], + "inherits": [ + "template" + ], "binaryDir": "${sourceDir}/build_macos", "condition": { "type": "equals", @@ -26,16 +28,22 @@ "rhs": "Darwin" }, "generator": "Xcode", - "warnings": {"dev": true, "deprecated": true}, + "warnings": { + "dev": true, + "deprecated": true + }, "cacheVariables": { "CMAKE_OSX_DEPLOYMENT_TARGET": "11.0", + "CMAKE_OSX_ARCHITECTURES": "arm64;x86_64", "CODESIGN_IDENTITY": "$penv{CODESIGN_IDENT}", "CODESIGN_TEAM": "$penv{CODESIGN_TEAM}" } }, { "name": "macos-ci", - "inherits": ["macos"], + "inherits": [ + "macos" + ], "displayName": "macOS Universal CI build", "description": "Build for macOS 11.0+ (Universal binary) for CI", "generator": "Xcode", @@ -48,7 +56,9 @@ "name": "windows-x64", "displayName": "Windows x64", "description": "Build for Windows x64", - "inherits": ["template"], + "inherits": [ + "template" + ], "binaryDir": "${sourceDir}/build_x64", "condition": { "type": "equals", @@ -57,11 +67,16 @@ }, "generator": "Visual Studio 17 2022", "architecture": "x64,version=10.0.22621", - "warnings": {"dev": true, "deprecated": true} + "warnings": { + "dev": true, + "deprecated": true + } }, { "name": "windows-ci-x64", - "inherits": ["windows-x64"], + "inherits": [ + "windows-x64" + ], "displayName": "Windows x64 CI build", "description": "Build for Windows x64 on CI", "cacheVariables": { @@ -72,7 +87,9 @@ "name": "ubuntu-x86_64", "displayName": "Ubuntu x86_64", "description": "Build for Ubuntu x86_64", - "inherits": ["template"], + "inherits": [ + "template" + ], "binaryDir": "${sourceDir}/build_x86_64", "condition": { "type": "equals", @@ -80,7 +97,10 @@ "rhs": "Linux" }, "generator": "Ninja", - "warnings": {"dev": true, "deprecated": true}, + "warnings": { + "dev": true, + "deprecated": true + }, "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo", "CMAKE_INSTALL_LIBDIR": "lib/CMAKE_SYSTEM_PROCESSOR-linux-gnu" @@ -88,7 +108,9 @@ }, { "name": "ubuntu-ci-x86_64", - "inherits": ["ubuntu-x86_64"], + "inherits": [ + "ubuntu-x86_64" + ], "displayName": "Ubuntu x86_64 CI build", "description": "Build for Ubuntu x86_64 on CI", "cacheVariables": { @@ -142,4 +164,4 @@ "configuration": "RelWithDebInfo" } ] -} +} \ No newline at end of file diff --git a/buildspec.json b/buildspec.json index 70ddaa3..88986b7 100644 --- a/buildspec.json +++ b/buildspec.json @@ -30,26 +30,6 @@ "macos": "84d8e966d8473bdb9bdb562cbd4db4e75af9867158bc9e5fb177d09f11aaff69", "windows-x64": "59deb7bc6d38984ca2e122a6a7e5660ee037d9219449ed101cc87aa5aa622b1f" } - }, - "cef": { - "version": "6533", - "baseUrl": "https://cdn-fastly.obsproject.com/downloads", - "label": "Chromium Embedded Framework", - "hashes": { - "macos-x86_64": "d494f1a18746ae65846853c844c1dcf5efa2348e0f422bcbd97059a536f24496", - "macos-arm64": "1bb59dbb759150e170796f641a4a84c59c0dea4ffef89477e9d811520af5d15a", - "ubuntu-x86_64": "cb7225c7a937ac4cdc9c41700061f45cccc640d696902357782e57f8250bf43a", - "ubuntu-aarch64": "f92df7f076bdc8cac2e3c77e27be418008b7168723201cb73fdbc2f6d91bc778", - "windows-x64": "922efbda1f2f8be9e5b2754d878a14d90afc81f04e94fc9101a7513e2b5cecc1", - "windows-arm64": "df9df4bd85826b4c071c6db404fd59cf93efd9c58ec3ab64e204466ae19bb02a" - }, - "revision": { - "macos-x86_64": 3, - "macos-arm64": 3, - "ubuntu-x86_64": 3, - "ubuntu-aarch64": 3, - "windows-x64": 2 - } } }, "platformConfig": { @@ -63,4 +43,4 @@ "author": "17live", "website": "https://17.live", "email": "app@17.live" -} +} \ No newline at end of file diff --git a/cmake/FindCEF.cmake b/cmake/FindCEF.cmake deleted file mode 100644 index 0d2a6db..0000000 --- a/cmake/FindCEF.cmake +++ /dev/null @@ -1,253 +0,0 @@ -#[=======================================================================[.rst -FindCEF ----------- - -FindModule for CEF and associated libraries - -.. versionchanged:: 3.0 - Updated FindModule to CMake standards - -Imported Targets -^^^^^^^^^^^^^^^^ - -.. versionadded:: 2.0 - -This module defines the :prop_tgt:`IMPORTED` targets: - -``CEF::Wrapper`` - Static library loading wrapper - -``CEF::Library`` - Chromium Embedded Library - -Result Variables -^^^^^^^^^^^^^^^^ - -This module sets the following variables: - -``CEF_FOUND`` - True, if all required components and the core library were found. -``CEF_VERSION`` - Detected version of found CEF libraries. - -Cache variables -^^^^^^^^^^^^^^^ - -The following cache variables may also be set: - -``CEF_LIBRARY_WRAPPER_RELEASE`` - Path to the optimized wrapper component of CEF. -``CEF_LIBRARY_WRAPPER_DEBUG`` - Path to the debug wrapper component of CEF. -``CEF_LIBRARY_RELEASE`` - Path to the library component of CEF. -``CEF_LIBRARY_DEBUG`` - Path to the debug library component of CEF. -``CEF_INCLUDE_DIR`` - Directory containing ``cef_version.h``. - -#]=======================================================================] - -include(FindPackageHandleStandardArgs) - -set(CEF_ROOT_DIR "" CACHE PATH "Alternative path to Chromium Embedded Framework") - -if(NOT DEFINED CEF_ROOT_DIR OR CEF_ROOT_DIR STREQUAL "") - message( - FATAL_ERROR - "CEF_ROOT_DIR is not set - if ENABLE_BROWSER is enabled, " - "a CEF distribution with compiled wrapper library is required.\n" - "Please download a CEF distribution for your appropriate architecture " - "and specify CEF_ROOT_DIR to its location" - ) -endif() - -find_path( - CEF_INCLUDE_DIR - "cef_version.h" - HINTS "${CEF_ROOT_DIR}/include" - DOC "Chromium Embedded Framework include directory." -) - -if(CEF_INCLUDE_DIR) - file( - STRINGS - "${CEF_INCLUDE_DIR}/cef_version.h" - _VERSION_STRING - REGEX "^.*CEF_VERSION_(MAJOR|MINOR|PATCH)[ \t]+[0-9]+[ \t]*$" - ) - string(REGEX REPLACE ".*CEF_VERSION_MAJOR[ \t]+([0-9]+).*" "\\1" VERSION_MAJOR "${_VERSION_STRING}") - string(REGEX REPLACE ".*CEF_VERSION_MINOR[ \t]+([0-9]+).*" "\\1" VERSION_MINOR "${_VERSION_STRING}") - string(REGEX REPLACE ".*CEF_VERSION_PATCH[ \t]+([0-9]+).*" "\\1" VERSION_PATCH "${_VERSION_STRING}") - set(CEF_VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}") -else() - if(NOT CEF_FIND_QUIETLY) - message(AUTHOR_WARNING "Failed to find Chromium Embedded Framework version.") - endif() - set(CEF_VERSION 0.0.0) -endif() - -if(CMAKE_HOST_SYSTEM_NAME STREQUAL Windows) - # Windows特定的CEF配置 - find_library( - CEF_IMPLIB_RELEASE - NAMES cef.lib libcef.lib - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}" "${CEF_ROOT_DIR}/Release" - DOC "Chromium Embedded Framework import library location" - ) - - # 在Windows上,我们需要查找导入库(.lib)而不是DLL文件 - find_library( - CEF_LIBRARY_RELEASE - NAMES cef libcef - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}" "${CEF_ROOT_DIR}/Release" - DOC "Chromium Embedded Framework library location" - ) - - if(NOT CEF_LIBRARY_RELEASE) - set(CEF_LIBRARY_RELEASE "${CEF_IMPLIB_RELEASE}") - endif() - - find_library( - CEF_LIBRARY_WRAPPER_RELEASE - NAMES cef_dll_wrapper libcef_dll_wrapper - NO_DEFAULT_PATH - PATHS - "${CEF_ROOT_DIR}/build/libcef_dll/Release" - "${CEF_ROOT_DIR}/build/libcef_dll_wrapper/Release" - "${CEF_ROOT_DIR}/build/libcef_dll" - "${CEF_ROOT_DIR}/build/libcef_dll_wrapper" - DOC "Chromium Embedded Framework static library wrapper." - ) - - find_library( - CEF_LIBRARY_WRAPPER_DEBUG - NAMES cef_dll_wrapper libcef_dll_wrapper - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}/build/libcef_dll/Debug" "${CEF_ROOT_DIR}/build/libcef_dll_wrapper/Debug" - DOC "Chromium Embedded Framework static library wrapper (debug)." - ) -elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL Darwin) - find_library( - CEF_LIBRARY_RELEASE - NAMES "Chromium Embedded Framework" - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}" "${CEF_ROOT_DIR}/Release" - DOC "Chromium Embedded Framework" - ) - - find_library( - CEF_LIBRARY_WRAPPER_RELEASE - NAMES cef_dll_wrapper libcef_dll_wrapper - NO_DEFAULT_PATH - PATHS - "${CEF_ROOT_DIR}/build/libcef_dll/Release" - "${CEF_ROOT_DIR}/build/libcef_dll_wrapper/Release" - "${CEF_ROOT_DIR}/build/libcef_dll" - "${CEF_ROOT_DIR}/build/libcef_dll_wrapper" - DOC "Chromium Embedded Framework static library wrapper." - ) - - find_library( - CEF_LIBRARY_WRAPPER_DEBUG - NAMES cef_dll_wrapper libcef_dll_wrapper - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}/build/libcef_dll/Debug" "${CEF_ROOT_DIR}/build/libcef_dll_wrapper/Debug" - DOC "Chromium Embedded Framework static library wrapper (debug)." - ) -elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL Linux) - find_library( - CEF_LIBRARY_RELEASE - NAMES libcef.so - NO_DEFAULT_PATH - PATHS "${CEF_ROOT_DIR}" "${CEF_ROOT_DIR}/Release" - DOC "Chromium Embedded Framework" - ) - - find_library( - CEF_LIBRARY_WRAPPER_RELEASE - NAMES cef_dll_wrapper.a libcef_dll_wrapper.a - NO_DEFAULT_PATH - PATHS - "${CEF_ROOT_DIR}/libcef_dll_wrapper" - "${CEF_ROOT_DIR}/build/libcef_dll" - "${CEF_ROOT_DIR}/build/libcef_dll_wrapper" - DOC "Chromium Embedded Framework static library wrapper." - ) -endif() - -include(SelectLibraryConfigurations) -select_library_configurations(CEF) - -find_package_handle_standard_args( - CEF - REQUIRED_VARS CEF_LIBRARY_RELEASE CEF_LIBRARY_WRAPPER_RELEASE CEF_INCLUDE_DIR - VERSION_VAR CEF_VERSION - REASON_FAILURE_MESSAGE "Ensure that location of pre-compiled Chromium Embedded Framework is set as CEF_ROOT_DIR." -) -mark_as_advanced(CEF_LIBRARY CEF_LIBRARY_WRAPPER_RELEASE CEF_LIBRARY_WRAPPER_DEBUG CEF_INCLUDE_DIR) - -if(NOT TARGET CEF::Wrapper) - if(IS_ABSOLUTE "${CEF_LIBRARY_WRAPPER_RELEASE}") - add_library(CEF::Wrapper STATIC IMPORTED) - set_property(TARGET CEF::Wrapper PROPERTY IMPORTED_LOCATION_RELEASE "${CEF_LIBRARY_WRAPPER_RELEASE}") - else() - add_library(CEF::Wrapper INTERFACE IMPORTED) - set_property(TARGET CEF::Wrapper PROPERTY IMPORTED_LIBNAME_RELEASE "${CEF_LIBRARY_WRAPPER_RELEASE}") - endif() - set_property(TARGET CEF::Wrapper APPEND PROPERTY IMPORTED_CONFIGURATIONS "Release") - - if(CEF_LIBRARY_WRAPPER_DEBUG) - if(IS_ABSOLUTE "${CEF_LIBRARY_WRAPPER_DEBUG}") - set_property(TARGET CEF::Wrapper PROPERTY IMPORTED_LOCATION_DEBUG "${CEF_LIBRARY_WRAPPER_DEBUG}") - else() - set_property(TARGET CEF::Wrapper PROPERTY IMPORTED_LIBNAME_DEBUG "${CEF_LIBRARY_WRAPPER_DEBUG}") - endif() - set_property(TARGET CEF::Wrapper APPEND PROPERTY IMPORTED_CONFIGURATIONS "Debug") - endif() - - set_property(TARGET CEF::Wrapper APPEND PROPERTY INTERFACE_INCLUDE_DIRECTORIES "${CEF_INCLUDE_DIR}" "${CEF_ROOT_DIR}") -endif() - -if(NOT TARGET CEF::Library) - if(CMAKE_HOST_SYSTEM_NAME STREQUAL Windows) - # 在Windows平台上,使用SHARED库类型并正确设置导入库 - add_library(CEF::Library SHARED IMPORTED) - if(DEFINED CEF_IMPLIB_RELEASE) - set_property(TARGET CEF::Library PROPERTY IMPORTED_IMPLIB_RELEASE "${CEF_IMPLIB_RELEASE}") - # 如果找到了DLL文件,设置IMPORTED_LOCATION - if(EXISTS "${CEF_ROOT_DIR}/Release/libcef.dll") - set_property(TARGET CEF::Library PROPERTY IMPORTED_LOCATION_RELEASE "${CEF_ROOT_DIR}/Release/libcef.dll") - elseif(EXISTS "${CEF_ROOT_DIR}/libcef.dll") - set_property(TARGET CEF::Library PROPERTY IMPORTED_LOCATION_RELEASE "${CEF_ROOT_DIR}/libcef.dll") - endif() - else() - # 如果没有找到导入库,使用STATIC类型 - add_library(CEF::Library STATIC IMPORTED) - set_property(TARGET CEF::Library PROPERTY IMPORTED_LOCATION_RELEASE "${CEF_LIBRARY_RELEASE}") - endif() - else() - # 非Windows平台的处理 - if(IS_ABSOLUTE "${CEF_LIBRARY_RELEASE}") - add_library(CEF::Library UNKNOWN IMPORTED) - set_property(TARGET CEF::Library PROPERTY IMPORTED_LOCATION_RELEASE "${CEF_LIBRARY_RELEASE}") - else() - add_library(CEF::Library INTERFACE IMPORTED) - set_property(TARGET CEF::Library PROPERTY IMPORTED_LIBNAME_RELEASE "${CEF_LIBRARY_RELEASE}") - endif() - endif() - - set_property(TARGET CEF::Library APPEND PROPERTY INTERFACE_INCLUDE_DIRECTORIES "${CEF_INCLUDE_DIR}" "${CEF_ROOT_DIR}") - set_property(TARGET CEF::Library PROPERTY IMPORTED_CONFIGURATIONS "Release") -endif() - -include(FeatureSummary) -set_package_properties( - CEF - PROPERTIES - URL "https://bitbucket.org/chromiumembedded/cef/" - DESCRIPTION - "Chromium Embedded Framework (CEF). A simple framework for embedding Chromium-based browsers in other applications." -) \ No newline at end of file diff --git a/cmake/FindMbedTLS.cmake b/cmake/FindMbedTLS.cmake new file mode 100644 index 0000000..d05beba --- /dev/null +++ b/cmake/FindMbedTLS.cmake @@ -0,0 +1,256 @@ +#[=======================================================================[.rst +FindMbedTLS +----------- + +FindModule for MbedTLS and associated libraries + +.. versionchanged:: 3.0 + Updated FindModule to CMake standards + +Components +^^^^^^^^^^ + +.. versionadded:: 1.0 + +This module contains provides several components: + +``mbedcrypto`` +``mbedtls`` +``mbedx509`` + +Import targets exist for each component. + +Imported Targets +^^^^^^^^^^^^^^^^ + +.. versionadded:: 2.0 + +This module defines the :prop_tgt:`IMPORTED` targets: + +``MbedTLS::mbedcrypto`` + Crypto component + +``MbedTLS::mbedtls`` + TLS component + +``MbedTLS::mbedX509`` + X509 component + +Result Variables +^^^^^^^^^^^^^^^^ + +This module sets the following variables: + +``MbedTLS_FOUND`` + True, if all required components and the core library were found. +``MbedTLS_VERSION`` + Detected version of found MbedTLS libraries. + +``MbedTLS__VERSION`` + Detected version of found MbedTLS component library. + +Cache variables +^^^^^^^^^^^^^^^ + +The following cache variables may also be set: + +``MbedTLS__LIBRARY`` + Path to the library component of MbedTLS. +``MbedTLS__INCLUDE_DIR`` + Directory containing ``.h``. + +#]=======================================================================] + +include(FindPackageHandleStandardArgs) + +find_package(PkgConfig QUIET) +if(PKG_CONFIG_FOUND) + pkg_check_modules(PC_MbedTLS QUIET mbedtls mbedcrypto mbedx509) +endif() + +# MbedTLS_set_soname: Set SONAME on imported library targets +macro(MbedTLS_set_soname component) + if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin") + execute_process( + COMMAND sh -c "otool -D '${Mbed${component}_LIBRARY}' | grep -v '${Mbed${component}_LIBRARY}'" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0 AND _output MATCHES "^@rpath/") + set_property(TARGET MbedTLS::mbed${component} PROPERTY IMPORTED_SONAME "${_output}") + endif() + elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + execute_process( + COMMAND sh -c "objdump -p '${Mbed${component}_LIBRARY}' | grep SONAME" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0) + string(REGEX REPLACE "[ \t]+SONAME[ \t]+([^ \t]+)" "\\1" _soname "${_output}") + set_property(TARGET MbedTLS::mbed${component} PROPERTY IMPORTED_SONAME "${_soname}") + unset(_soname) + endif() + endif() + unset(_output) + unset(_result) +endmacro() + +find_path( + MbedTLS_INCLUDE_DIR + NAMES mbedtls/ssl.h + HINTS "${PC_MbedTLS_INCLUDE_DIRS}" "${OBS_DEPS_DIR}/include" + PATHS /usr/include /usr/local/include + DOC "MbedTLS include directory" +) + +if(PC_MbedTLS_VERSION VERSION_GREATER 0) + set(MbedTLS_VERSION ${PC_MbedTLS_VERSION}) +elseif(EXISTS "${MbedTLS_INCLUDE_DIR}/mbedtls/build_info.h") + file( + STRINGS + "${MbedTLS_INCLUDE_DIR}/mbedtls/build_info.h" + _VERSION_STRING + REGEX "#define[ \t]+MBEDTLS_VERSION_STRING[ \t]+.+" + ) + string( + REGEX REPLACE + ".*#define[ \t]+MBEDTLS_VERSION_STRING[ \t]+\"(.+)\".*" + "\\1" + MbedTLS_VERSION + "${_VERSION_STRING}" + ) +elseif(EXISTS "${MbedTLS_INCLUDE_DIR}/mbedtls/version.h") + file( + STRINGS + "${MbedTLS_INCLUDE_DIR}/mbedtls/version.h" + _VERSION_STRING + REGEX "#define[ \t]+MBEDTLS_VERSION_STRING[ \t]+.+" + ) + string( + REGEX REPLACE + ".*#define[ \t]+MBEDTLS_VERSION_STRING[ \t]+\"(.+)\".*" + "\\1" + MbedTLS_VERSION + "${_VERSION_STRING}" + ) +else() + if(NOT MbedTLS_FIND_QUIETLY) + message(AUTHOR_WARNING "Failed to find MbedTLS version.") + endif() + set(MbedTLS_VERSION 0.0.0) +endif() + +if(MbedTLS_VERSION VERSION_GREATER_EQUAL 3.6.0) + message( + DEPRECATION + "Use of the custom CMake find module for MbedTLS versions >= 3.6.0 is not supported - build errors might occur!" + ) +endif() + +find_library( + Mbedtls_LIBRARY + NAMES libmbedtls mbedtls + HINTS "${PC_MbedTLS_LIBRARY_DIRS}" "${OBS_DEPS_DIR}/lib" + PATHS /usr/lib /usr/local/lib + DOC "MbedTLS location" +) + +find_library( + Mbedcrypto_LIBRARY + NAMES libmbedcrypto mbedcrypto + HINTS "${PC_MbedTLS_LIBRARY_DIRS}" "${OBS_DEPS_DIR}/lib" + PATHS /usr/lib /usr/local/lib + DOC "MbedCrypto location" +) + +find_library( + Mbedx509_LIBRARY + NAMES libmbedx509 mbedx509 + HINTS "${PC_MbedTLS_LIBRARY_DIRS}" "${OBS_DEPS_DIR}/lib" + PATHS /usr/lib /usr/local/lib + DOC "MbedX509 location" +) + +if(Mbedtls_LIBRARY AND NOT Mbedcrypto_LIBRARY AND NOT Mbedx509_LIBRARY) + set(CMAKE_REQUIRED_LIBRARIES "${Mbedtls_LIBRARY}") + set(CMAKE_REQUIRED_INCLUDES "${MbedTLS_INCLUDE_DIR}") + + check_symbol_exists(mbedtls_x509_crt_init "mbedtls/x509_crt.h" MbedTLS_INCLUDES_X509) + check_symbol_exists(mbedtls_sha256_init "mbedtls/sha256.h" MbedTLS_INCLUDES_CRYPTO) + unset(CMAKE_REQUIRED_LIBRARIES) + unset(CMAKE_REQUIRED_INCLUDES) +endif() + +if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin|Windows") + set(MbedTLS_ERROR_REASON "Ensure that obs-deps is provided as part of CMAKE_PREFIX_PATH.") +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + set(MbedTLS_ERROR_REASON "Ensure that MbedTLS is installed on the system.") +endif() + +if(MbedTLS_INCLUDES_X509 AND MbedTLS_INCLUDES_CRYPTO) + find_package_handle_standard_args( + MbedTLS + REQUIRED_VARS Mbedtls_LIBRARY MbedTLS_INCLUDE_DIR + VERSION_VAR MbedTLS_VERSION + REASON_FAILURE_MESSAGE "${MbedTLS_ERROR_REASON}" + ) + mark_as_advanced(Mbedtls_LIBRARY MbedTLS_INCLUDE_DIR) + list(APPEND _COMPONENTS tls) +else() + find_package_handle_standard_args( + MbedTLS + REQUIRED_VARS Mbedtls_LIBRARY Mbedcrypto_LIBRARY Mbedx509_LIBRARY MbedTLS_INCLUDE_DIR + VERSION_VAR MbedTLS_VERSION + REASON_FAILURE_MESSAGE "${MbedTLS_ERROR_REASON}" + ) + mark_as_advanced(Mbedtls_LIBRARY Mbedcrypto_LIBRARY Mbedx509_LIBRARY MbedTLS_INCLUDE_DIR) + list(APPEND _COMPONENTS tls crypto x509) +endif() +unset(MbedTLS_ERROR_REASON) + +if(MbedTLS_FOUND) + foreach(component IN LISTS _COMPONENTS) + if(NOT TARGET MbedTLS::mbed${component}) + if(IS_ABSOLUTE "${Mbed${component}_LIBRARY}") + add_library(MbedTLS::mbed${component} UNKNOWN IMPORTED) + set_property(TARGET MbedTLS::mbed${component} PROPERTY IMPORTED_LOCATION "${Mbed${component}_LIBRARY}") + else() + add_library(MbedTLS::mbed${component} INTERFACE IMPORTED) + set_property(TARGET MbedTLS::mbed${component} PROPERTY IMPORTED_LIBNAME "${Mbed${component}_LIBRARY}") + endif() + + MbedTLS_set_soname(${component}) + set_target_properties( + MbedTLS::mbed${component} + PROPERTIES + INTERFACE_COMPILE_OPTIONS "${PC_MbedTLS_CFLAGS_OTHER}" + INTERFACE_INCLUDE_DIRECTORIES "${MbedTLS_INCLUDE_DIR}" + VERSION ${MbedTLS_VERSION} + ) + + # Windows-specific: do not force runtime library settings, let actual compilation target decide + if(OS_WINDOWS) + # When MbedTLS is linked as dynamic library, no need to force runtime library settings + # Runtime library compatibility is handled by main target through legacy_stdio_definitions.lib + endif() + endif() + endforeach() + + if(MbedTLS_INCLUDES_X509 AND MbedTLS_INCLUDES_CRYPTO) + set(MbedTLS_LIBRARIES ${Mbedtls_LIBRARY}) + else() + set(MbedTLS_LIBRARIES ${Mbedtls_LIBRARY} ${Mbedcrypto_LIBRARY} ${Mbedx509_LIBRARY}) + set_property(TARGET MbedTLS::mbedtls PROPERTY INTERFACE_LINK_LIBRARIES MbedTLS::mbedcrypto MbedTLS::mbedx509) + endif() +endif() + +include(FeatureSummary) +set_package_properties( + MbedTLS + PROPERTIES + URL "https://www.trustedfirmware.org/projects/mbed-tls" + DESCRIPTION + "A C library implementing cryptographic primitives, X.509 certificate manipulation, and the SSL/TLS and DTLS protocols." +) diff --git a/cmake/common/buildspec_common.cmake b/cmake/common/buildspec_common.cmake index 15c2799..6f9b4d9 100644 --- a/cmake/common/buildspec_common.cmake +++ b/cmake/common/buildspec_common.cmake @@ -144,15 +144,6 @@ function(_check_dependencies) string(JSON dependency_data GET ${buildspec} dependencies) foreach(dependency IN LISTS dependencies_list) - set(orig_arch ${arch}) - set(orig_platform ${platform}) - - if(dependency STREQUAL cef AND OS_MACOS) - set(arch ${CMAKE_OSX_ARCHITECTURES}) - - set(platform macos-${arch}) - endif() - string(JSON data GET ${dependency_data} ${dependency}) string(JSON version GET ${data} version) string(JSON hash GET ${data} hashes ${platform}) @@ -195,22 +186,14 @@ function(_check_dependencies) set(skip TRUE) endif() endif() - elseif(dependency STREQUAL cef) - if(OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash} AND(CEF_ROOT_DIR AND EXISTS "${CEF_ROOT_DIR}")) - set(skip TRUE) - endif() endif() if(skip) message(STATUS "Setting up ${label} (${arch}) - skipped") - - # skip but restore original values for next iteration - set(arch ${orig_arch}) - set(platform ${orig_platform}) continue() endif() - if(dependency STREQUAL obs-studio OR dependency STREQUAL cef) + if(dependency STREQUAL obs-studio) set(url ${url}/${file}) else() set(url ${url}/${version}/${file}) @@ -250,20 +233,16 @@ function(_check_dependencies) if(dependency STREQUAL prebuilt) list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + set(OBS_DEPS_DIR "${dependencies_dir}/${destination}" CACHE PATH "OBS pre-built dependencies directory" FORCE) elseif(dependency STREQUAL qt6) list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") elseif(dependency STREQUAL obs-studio) set(_obs_version ${version}) set(_obs_destination "${destination}") list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}") - elseif(dependency STREQUAL cef) - set(CEF_ROOT_DIR "${dependencies_dir}/${destination}" CACHE PATH "CEF root directory" FORCE) endif() message(STATUS "Setting up ${label} (${arch}) - done") - - set(arch ${orig_arch}) - set(platform ${orig_platform}) endforeach() list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH) diff --git a/cmake/macos/buildspec.cmake b/cmake/macos/buildspec.cmake index 8ff79af..c189782 100644 --- a/cmake/macos/buildspec.cmake +++ b/cmake/macos/buildspec.cmake @@ -18,9 +18,7 @@ function(_check_dependencies_macos) set(qt6_destination "obs-deps-qt6-VERSION-ARCH") set(obs-studio_filename "VERSION.tar.gz") set(obs-studio_destination "obs-studio-VERSION") - set(cef_filename "cef_binary_VERSION_macos_ARCH_REVISION.tar.xz") - set(cef_destination "cef_binary_VERSION_macos_ARCH") - set(dependencies_list prebuilt qt6 cef obs-studio) + set(dependencies_list prebuilt qt6 obs-studio) _check_dependencies() diff --git a/cmake/windows/buildspec.cmake b/cmake/windows/buildspec.cmake index 6e4dd9d..e0e5ee0 100644 --- a/cmake/windows/buildspec.cmake +++ b/cmake/windows/buildspec.cmake @@ -16,9 +16,7 @@ function(_check_dependencies_windows) set(qt6_destination "obs-deps-qt6-VERSION-ARCH") set(obs-studio_filename "VERSION.zip") set(obs-studio_destination "obs-studio-VERSION") - set(cef_filename "cef_binary_VERSION_windows_ARCH_REVISION.zip") - set(cef_destination "cef_binary_VERSION_windows_ARCH") - set(dependencies_list prebuilt qt6 cef obs-studio) + set(dependencies_list prebuilt qt6 obs-studio) _check_dependencies() endfunction() diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 997c96a..12ef9d1 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -1,24 +1,20 @@ -# OBS 17Live Plugin Locale Keys - en-US -# Updated from Excel on 2025-09-17 17:22:40 -# Please review the translations for accuracy +; OBS 17LIVE Plugin Language File +; Generated automatically by generate_obs_locale.py on 2025-11-27T22:27:47.373356 -17Live="17Live" +17LIVE="17LIVE" Auth.Caption="17LIVE ID Login" Auth.Error01="Username or password is incorrect" Auth.Error02="Login failed: %1" Auth.ForgotPassword="Forgot your password?" -Auth.Help="More Login Help" +Auth.Help="More Login Help" Auth.Hint01="Please be cautious: 17LIVE will never ask for your account information, ATM operations, or credit card details under the pretext of failed installment payments or similar reasons." Auth.LoginSuccess="Login Successful" -Auth.LoginSuccess.Tip="Welcome to 17Live, %1!" +Auth.LoginSuccess.Tip="Welcome to 17LIVE, %1!" Auth.Password="Password" -Auth.Password.Placeholder="********" Auth.Password.Tip="If you have verified your phone number, you can click 'Forgot Password' on the right to receive a new password and log in with your 17 account, or click 'More Login Help' below to view the help article." Auth.Register="Register a new account" Auth.SignIn="Sign In" -Auth.Username="Account" ChatRoom.Title="Chat Room" -CustomEvent.Cancel="Cancel" CustomEvent.Close="Close" CustomEvent.Confirm.Close.Title="Turn off Custom Event Confirmation" CustomEvent.Confirm.Close.Yes="Confirm Close" @@ -39,28 +35,52 @@ CustomEvent.Error.CreateFailed="Failed to create event" CustomEvent.Error.DescriptionTooLong="Event description cannot exceed 200 characters" CustomEvent.Error.EmptyDescription="Please enter an event description" CustomEvent.Error.EmptyTitle="Event title cannot be empty" -CustomEvent.Error.GiftNotSelected="Please select event gifts" CustomEvent.Error.MaxGifts="You can only select up to %1 gifts" CustomEvent.Error.NoGifts="At least one gift must be selected" CustomEvent.Error.StopFailed="Failed to stop event" -CustomEvent.Error.Title="Error" -CustomEvent.Error.TitleEmpty="Please enter an event title" CustomEvent.Gifts="Event Gifts" CustomEvent.LoadingGifts="Loading gifts..." CustomEvent.SelectedGifts.Placeholder="Select gifts" CustomEvent.Stop="Stop" -CustomEvent.Success="Success" -CustomEvent.Success.Created="Custom event created successfully!" CustomEvent.Title="Event Title" CustomEvent.Title.Placeholder="Example: Let's celebrate my birthday together!" CustomEvent.TotalGoal="Overall Goal (Baby Coins)" CustomEvent.TotalGoal.Placeholder="88888888" -Error.Confirm="OK" +Diagnostics.Browse="Browse…" +Diagnostics.Cancel="Cancel" +Diagnostics.Categories.ConfigSnapshot="Settings Snapshot" +Diagnostics.Categories.CrashInfo="Crash Information" +Diagnostics.Categories.NetworkLogs="Network Logs" +Diagnostics.Categories.NetworkRequests="Network Requests" +Diagnostics.Categories.OBSLogs="OBS Logs" +Diagnostics.Categories.PluginLogs="Plugin Logs" +Diagnostics.Categories.PrivacyFilter="Privacy Filter" +Diagnostics.Categories.SystemInfo="System Information" +Diagnostics.Categories.Title="Categories" +Diagnostics.Collect="Collect" +Diagnostics.Description="This tool collects diagnostic information to help troubleshoot issues. All collected data is saved locally and is never uploaded automatically." +Diagnostics.OutputPath="Output path:" +Diagnostics.Privacy.WarningMessage="Warning: Some diagnostic data may include personal information. Please review the content carefully before sharing." +Diagnostics.Privacy.WarningTitle="Privacy Warning" +Diagnostics.SavePackage="Save Package" +Diagnostics.Status.Collecting="Collecting…" +Diagnostics.Status.CollectorFailed="Failed to create diagnostics collector" +Diagnostics.Status.Error="Error" +Diagnostics.Status.ErrorMessage="Failed to create the diagnostic package" +Diagnostics.Status.ErrorTitle="Diagnostic Collection Failed" +Diagnostics.Status.Failed="Failed" +Diagnostics.Status.FilesCollected="Files Collected" +Diagnostics.Status.OpenFolderQuestion="Would you like to open the folder containing these files?" +Diagnostics.Status.OpenFolderTitle="Open Folder" +Diagnostics.Status.Output="Output" +Diagnostics.Status.Ready="Ready" +Diagnostics.Status.Success="Success" +Diagnostics.Title="Diagnostics Collector" Live.ChangeEvent.Failed="Event changes require a 5-minute interval." Live.Common.Notice="Notice" Live.Common.StreamingInProgress="Currently streaming, operation temporarily unavailable" Live.Create.Feature207Disabled="Live streaming is temporarily not supported in this region" -Live.Create.GetSelfInfoFailed="Failed to get streaming information. Please sign out, sign in again, and try again" +Live.Create.GetSelfInfoFailed="Failed to get streaming information. Please restart OBS" Live.Create.Title="Live Streaming Not Supported" Live.EventChange.Confirm.Cancel="Cancel" Live.EventChange.Confirm.Confirm="Confirm" @@ -89,42 +109,28 @@ Live.Settings.CloseLive.Confirm.Button="End Livestream" Live.Settings.Error="Error" Live.Settings.Event="Event" Live.Settings.Event.Tip="You can switch events once every 5 minutes." -Live.Settings.GetRoomInfoError="Failed to get Room Info" -Live.Settings.GetRtmpError="Failed to get RTMP settings" Live.Settings.GroupCall="Group Call" Live.Settings.GroupCall.Help.Button="Got it" Live.Settings.GroupCall.Help.Content="

How to Enable Party Live

1. Toggle the \"Group Call\" on in the OBS

Once enabled, you can join or receive an invite to a group call.

2. Set up the Group Call on 17LIVE App

After starting a livestream on OBS, go on 17LIVE App to enter \"Remote Controller\" and hit the button on the top right to start a Group Call. For more details, please refer to FAQs.

3. To Avoid Echo Issues

To avoid echo, use headphones to listen to the group call chat on remote controller, or keep the remote controller away from the computer microphone. You can also mute the microphone in OBS while the other person is speaking.

" Live.Settings.GroupCall.Help.Title="How to Start a Group Call?" Live.Settings.GroupCall.Help.Tooltip="Click to view Party Live instructions" -Live.Settings.GroupCall.Tip="Toggle this on to join or receive the invite to a Group Call." +Live.Settings.GroupCall.Tip="Toggle this on to join or receive the invite to a Group Call. However, please toggle off if you want to use Multi-Platform Streaming." Live.Settings.Layout="Broadcast layout" -Live.Settings.Layout.Landscape="Portrait" -Live.Settings.Layout.Portrait="Landscape" -Live.Settings.LiveCreated="17Live Live Session Created" -Live.Settings.LiveCreated.Tip="The 17Live session has been created. Click 'Start Live and Stream' to start the session and begin streaming; click 'Start Live Only' to start the session without streaming; click 'Close Live' to end the session." +Live.Settings.Layout.Landscape="Landscape" +Live.Settings.Layout.Portrait="Portrait" +Live.Settings.LiveCreated="17LIVE Live Session Created" +Live.Settings.LiveCreated.Tip="The 17LIVE session has been created. Click 'Start Live and Stream' to start the session and begin streaming; click 'Start Live Only' to start the session without streaming; click 'Close Live' to end the session." Live.Settings.LiveNotification="Live Notification" -Live.Settings.LoadError="Failed to load live room information" Live.Settings.Loading="Loading live room data, please wait..." Live.Settings.No="No" -Live.Settings.Rank0.army_only_stream_level_setting_all_level="All Ranks. Qualified Members: {subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_level="{name} and above. Qualified Members: {subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_top_level="{name}. Qualified Members: {subscribersAmount}" -Live.Settings.Rank0.army_rank_name_1="Sergeant" -Live.Settings.Rank0.army_rank_name_2="Captain" -Live.Settings.Rank0.army_rank_name_3="Colonel" -Live.Settings.Rank0.army_rank_name_4="General" -Live.Settings.Rank0.army_rank_name_5="Corporal" -Live.Settings.Rank1.army_only_stream_level_setting_all_level="All Ranks. Qualified Members: {subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_level="Rank {value} and above. Qualified Members: {subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_top_level="Rank {value}. Qualified Members: {subscribersAmount}" +Live.Settings.RequiredDataMissing="Required data is missing. Please check your network connection and try again." +Live.Settings.Retry="Retry" Live.Settings.Save="Save settings" -Live.Settings.Save.Category.Empty="Please select a stream category" Live.Settings.Save.Success="Settings saved successfully" Live.Settings.Save.Title="Save Settings" Live.Settings.Save.Title.Empty="Please enter a stream title" Live.Settings.ShowInHotPage="Show in Hot Page" Live.Settings.StartLive="Start Live" -Live.Settings.StartLiveAndStream="Start Live and Stream" Live.Settings.StartLiveOnly="Start Live Only" Live.Settings.StartStreaming="Start Streaming" Live.Settings.StartStreaming.Tip="Do you want to start streaming at the same time?" @@ -138,6 +144,7 @@ Live.Settings.UserCondition="User Condition" Live.Settings.VirtualLiver="Yes, I'm a virtual streamer." Live.Settings.VirtualLiver.Tip="If you're a virtual streamer, please check this box so users can find your stream more easily." Live.Settings.VirtualLiver.Title="Are you a virtual streamer?" +Live.Settings.Warning="Warning" Live.Settings.Yes="Yes" Live.StreamList="Live Stream List" Live.StreamList.Empty="No live stream records. Go to 'Broadcast Settings' to set up." @@ -149,40 +156,84 @@ Menu.Broadcast="Broadcast" Menu.ChatRoom="Chat Room" Menu.CheckUpdate="Check Update" Menu.CheckUpdate.Url="https://github.com/17media/obs-plugin/releases/latest" +Menu.Diagnostics="One-Click Support" Menu.Dock="17LIVE Panel" Menu.Help="Help" Menu.Help.Url="https://17mediahelp.zendesk.com/hc/en-us/articles/31079492940313-Stream-with-OBS" Menu.LiveList="Live List" -Menu.RockZone="Rock Zone" -Menu.Settings="Settings" +Menu.PreviewDock="Preview Window" +Menu.RockZone="Rock Area" Menu.SignIn="Sign In" Menu.SignOut="Sign Out" +MultiRTMP.AddStream="Add Stream" +MultiRTMP.AddStream.Title="Add Streaming Platform" +MultiRTMP.AuthorizationFailed.Text="Authorization failed: %1\n\nPlease check your credentials and try again." +MultiRTMP.AuthorizationFailed.Title="Authorization Failed" +MultiRTMP.Config.Encoder.Audio="Audio Encoder" +MultiRTMP.Config.Encoder.Video="Video Encoder" +MultiRTMP.Config.Tab.Audio="Audio" +MultiRTMP.Config.Tab.Output="Output" +MultiRTMP.Config.Tab.Video="Video" +MultiRTMP.Config.Title="Multi-Platform Stream Settings" +MultiRTMP.Delete="Delete" +MultiRTMP.Delete.Confirm="Are you sure you want to delete this platform?" +MultiRTMP.Delete.Title="Delete Platform" +MultiRTMP.Dock.StartAll="Start All" +MultiRTMP.Dock.StartAll.WithCount="Start All (%1)" +MultiRTMP.Dock.StopAll="Stop All" +MultiRTMP.Dock.StopAll.WithCount="Stop All (%1)" +MultiRTMP.Dock.Title="Multi-Platform Streams" +MultiRTMP.Edit="Edit" +MultiRTMP.EditStream.Title="Edit Platform" +MultiRTMP.Error.AddFailed="Failed to add platform" +MultiRTMP.Error.Title="Multi-Platform Stream Error" +MultiRTMP.Error.UpdateFailed="Failed to update platform" +MultiRTMP.List.EmptyTip="Please create a new stream" +MultiRTMP.Precheck.GroupCallNotSupported="Party Live (Group Call) is not supported for multi-RTMP. Please end the current live and start again." +MultiRTMP.Precheck.LiveNotStarted="Please start your 17LIVE livestream first, then start Multi-RTMP." +MultiRTMP.Precheck.StartObsStreamingFirst="17LIVE live has started but OBS is not streaming. Please start streaming first." +MultiRTMP.Precheck.StreamManagerUnavailable="17LIVE stream service is unavailable. Please restart OBS and try again." +MultiRTMP.Start="Start" +MultiRTMP.Stats.Duration="Connection Duration" +MultiRTMP.Stats.FrameRate="Frame Rate" +MultiRTMP.Stats.UploadRate="Upload Rate" +MultiRTMP.Status.Connected="Connected" +MultiRTMP.Status.Connecting="Connecting" +MultiRTMP.Status.Disconnected="Disconnected" +MultiRTMP.Stop="Stop" +MultiRTMP.StopAllConfirm="Stop streaming on all platforms?" +MultiRTMP.Wait="Please wait…" +MultiRtmp.Config.AdvancedSettings="Advanced Settings" +MultiRtmp.Config.Audio.UseOBS="Use OBS Audio Settings" +MultiRtmp.Config.Authorize="Authorize Login" +MultiRtmp.Config.Cancel="Cancel" +MultiRtmp.Config.Confirm="OK" +MultiRtmp.Config.Deauthorize="Deauthorize" +MultiRtmp.Config.Protocol="Protocol" +MultiRtmp.Config.StreamName="Stream Name" +MultiRtmp.Config.Video.FPSDenominator="FPS Denominator" +MultiRtmp.Config.Video.OutputScene="Output Scene" +MultiRtmp.Config.Video.Resolution="Resolution" +MultiRtmp.Config.Video.UseOBS="Use OBS Video Settings" +PreviewDock.Tip.AnimationOnly="This window is for previewing gift animations only and will not be broadcast. During landscape streaming, listeners will not see the gift animations." +PreviewDock.Title="Preview Window" RockZone.Badge.Army.Template="Rank %1 (%2)" RockZone.Badge.Guardian="Guardian" RockZone.Badge.TopContributor="Top Contributor" -RockZone.EmptyList="No gift sender" +RockZone.EmptyList="No viewers are currently gifting." RockZone.Followers="Followers" RockZone.Following="Following" RockZone.Hint="Only the top 50 users are shown in the list." RockZone.Likes="Likes" -RockZone.Loading="Loading users..." RockZone.PokeAll="Poke All Friends" -RockZone.PokeAll.Success="Successfully poked all users." -RockZone.PokeUser="Poke" -RockZone.PokeUser.Success="Successfully poked this user." -RockZone.Title="Rock Zone" -RockZone.UserCard.followers="followers" -RockZone.UserCard.following="following" -RockZone.UserCard.likes="likes" +RockZone.PokeUser="Poke Him" +RockZone.Title="Rock Area" Update.Cancel="Cancel" -Update.CheckFailed="Update Check Failed" -Update.CheckFailed.Message="Unable to check for updates: %1" Update.DownloadComplete="Download Complete" Update.DownloadComplete.Message="The update file has been downloaded to: %1\n\nPlease manually install the downloaded file." Update.DownloadFailed="Download Failed" Update.DownloadFailed.NetworkError="Download failed: %1" Update.DownloadFailed.NoPackage="No suitable installer found for your system." -Update.DownloadFailed.SaveError="Unable to save the downloaded file." Update.DownloadProgress="Downloading the update file... %1/%2 MB" Update.Downloading="Downloading the update file..." Update.FileExists="File Already Exists" @@ -190,6 +241,52 @@ Update.FileExists.Message="File %1 already exists.\n\nWould you like to download Update.InstallReminder="Installation Reminder" Update.InstallReminder.Message="Please manually install %1 from the downloads folder" Update.NewVersionFound="New Version Available" -Update.NewVersionFound.Message="A new version of the 17Live OBS plugin (%1) is available.\n\nDo you want to download and install the update?" -Update.NewVersionFound.No="No" -Update.NewVersionFound.Yes="Yes" +Update.NewVersionFound.Message="A new version of the 17LIVE OBS plugin (%1) is available.\n\nDo you want to download and install the update?" + +MultiRTMP.ErrTitle.MissingServerKey="Missing params" +MultiRTMP.ErrDesc.MissingServerKey="Server URL or key is empty" +MultiRTMP.ErrSolution.MissingServerKey="Check config or re-fetch stream info" + +MultiRTMP.ErrTitle.UnknownPlatform="Unknown platform" +MultiRTMP.ErrDesc.UnknownPlatform="Unrecognized streaming platform" +MultiRTMP.ErrSolution.UnknownPlatform="Change platform or check platform settings" + +MultiRTMP.ErrTitle.AuthInvalid="Auth invalid" +MultiRTMP.ErrDesc.AuthInvalid="Login or token is invalid" +MultiRTMP.ErrSolution.AuthInvalid="Re-login or refresh token" + +MultiRTMP.ErrTitle.StartFailed="Start failed" +MultiRTMP.ErrDesc.StartFailed="OBS output start error" +MultiRTMP.ErrSolution.StartFailed="Check encoder settings and stream params" + +MultiRTMP.ErrTitle.ConnectFailed="Connect failed" +MultiRTMP.ErrDesc.ConnectFailed="Network connection or handshake failed" +MultiRTMP.ErrSolution.ConnectFailed="Check network and server URL" + +MultiRTMP.ErrTitle.APIError="API error" +MultiRTMP.ErrDesc.APIError="Platform API returned error" +MultiRTMP.ErrSolution.APIError="Retry later or contact support" + +MultiRTMP.ErrTitle.Generic="Start failed" +MultiRTMP.ErrDesc.Generic="Unknown error occurred" +MultiRTMP.ErrSolution.Generic="Check network and settings; contact support if needed" +MultiRtmp.Config.Reauthorize="Authorized — click to re-authorize" +MultiRtmp.Config.Deauthorize="Deauthorize" +MultiRTMP.ErrTitle.NetworkError="Network error" +MultiRTMP.ErrDesc.NetworkError="Network connection or handshake failure" +MultiRTMP.ErrSolution.NetworkError="Check network/proxy/firewall/DNS and time; retry or switch network" + +MultiRTMP.CloseLive.InfoTip="This action will not stop streaming on other platforms" +MultiRTMP.Stop.InfoTip17LIVE="This action will not stop the 17LIVE stream" + +Live.Settings.AutoRes.Title="Auto Set Resolution" +Live.Settings.AutoRes.Msg.Landscape="Landscape mode selected. OBS base and output resolutions will be set to 1280x720.\n\nYou can manually adjust and override these settings in OBS Settings before streaming." +Live.Settings.AutoRes.Msg.Portrait="Portrait mode selected. OBS base and output resolutions will be set to 720x1280.\n\nYou can manually adjust and override these settings in OBS Settings before streaming." +PreviewDock.LoadingGifts="Loading animations..." +PreviewDock.Initializing="Initializing video display..." +ChatRoom.LoadingGifts="Loading 17LIVE gift data..." +Live.Create.Failed="Failed to create live stream" + +Api.Error.39="Phone verification is required. Please complete in the app to begin streaming." +Api.Error.35="The action cannot be completed. Please check your profile information and picture." +Api.Error.Generic="The action cannot be completed. Please contact customer support for help. (%1)" diff --git a/data/locale/ja-JP.ini b/data/locale/ja-JP.ini index 3d9502f..d2af447 100644 --- a/data/locale/ja-JP.ini +++ b/data/locale/ja-JP.ini @@ -1,24 +1,20 @@ -# OBS 17Live Plugin Locale Keys - ja-JP -# Updated from Excel on 2025-09-17 17:22:40 -# Please review the translations for accuracy +; OBS 17LIVE Plugin Language File +; Generated automatically by generate_obs_locale.py on 2025-11-27T22:27:47.373699 -17Live="17Live" +17LIVE="17LIVE" Auth.Caption="17LIVE IDでログイン" Auth.Error01="IDまたはパスワードが正しくありません" Auth.Error02="ログインできません" Auth.ForgotPassword="パスワードを忘れた" -Auth.Help="ログインに関するヘルプ" +Auth.Help="ログインに関するヘルプ" Auth.Hint01="※17LIVEがお客様の口座情報やクレジットカードの詳細を尋ねたり、ATMでの操作をお願いしたりすることは一切ございませんので、ご注意ください。" Auth.LoginSuccess="ログイン完了" Auth.LoginSuccess.Tip="%1さん、17LIVEへようこそ!" Auth.Password="パスワード" -Auth.Password.Placeholder="********" Auth.Password.Tip="電話番号を認証済みの場合は、右側の「パスワードを忘れた」をクリックして新しいパスワードを取得し、17 LIVE IDとパスワードでログインできます。詳細については、下の「ログインに関するヘルプ」をご覧ください。" Auth.Register="新規登録" Auth.SignIn="ログイン" -Auth.Username="17LIVE ID" ChatRoom.Title="コメント" -CustomEvent.Cancel="キャンセル" CustomEvent.Close="閉じる" CustomEvent.Confirm.Close.Title="イベントの終了確認" CustomEvent.Confirm.Close.Yes="終了する" @@ -39,28 +35,52 @@ CustomEvent.Error.CreateFailed="イベントの作成に失敗しました" CustomEvent.Error.DescriptionTooLong="イベント詳細は200文字以内で入力してください" CustomEvent.Error.EmptyDescription="イベント詳細を入力してください" CustomEvent.Error.EmptyTitle="イベントのタイトルは空にできません" -CustomEvent.Error.GiftNotSelected="イベントギフトを選択してください" CustomEvent.Error.MaxGifts="ギフトは最大%1個まで選択できます" CustomEvent.Error.NoGifts="ギフトを選択する必要があります" CustomEvent.Error.StopFailed="イベントの中止に失敗しました" -CustomEvent.Error.Title="エラー" -CustomEvent.Error.TitleEmpty="イベント名を入力してください" CustomEvent.Gifts="イベントギフト" CustomEvent.LoadingGifts="ギフトを読み込み中..." CustomEvent.SelectedGifts.Placeholder="ギフトを選択" CustomEvent.Stop="停止" -CustomEvent.Success="保存完了" -CustomEvent.Success.Created="カスタムイベントが正常に作成されました!" CustomEvent.Title="イベント名" CustomEvent.Title.Placeholder="例:一緒に誕生日を祝いましょう!" CustomEvent.TotalGoal="累計ゴール(ベイビーコイン)" CustomEvent.TotalGoal.Placeholder="88888888" -Error.Confirm="OK" +Diagnostics.Browse="参照…" +Diagnostics.Cancel="キャンセル" +Diagnostics.Categories.ConfigSnapshot="設定スナップショット" +Diagnostics.Categories.CrashInfo="クラッシュ情報" +Diagnostics.Categories.NetworkLogs="ネットワークログ" +Diagnostics.Categories.NetworkRequests="ネットワークリクエスト" +Diagnostics.Categories.OBSLogs="OBSログ" +Diagnostics.Categories.PluginLogs="プラグインログ" +Diagnostics.Categories.PrivacyFilter="プライバシーフィルター" +Diagnostics.Categories.SystemInfo="システム情報" +Diagnostics.Categories.Title="カテゴリ" +Diagnostics.Collect="収集" +Diagnostics.Description="このツールは問題解決のための診断情報を収集します。データはローカルに保存され、自動的にアップロードされることはありません。" +Diagnostics.OutputPath="出力パス:" +Diagnostics.Privacy.WarningMessage="個人情報を含む可能性があります。共有する前に内容をご確認ください。" +Diagnostics.Privacy.WarningTitle="プライバシー警告" +Diagnostics.SavePackage="パッケージを保存" +Diagnostics.Status.Collecting="収集中…" +Diagnostics.Status.CollectorFailed="診断コレクターの作成に失敗しました" +Diagnostics.Status.Error="エラー" +Diagnostics.Status.ErrorMessage="診断パッケージの作成に失敗しました" +Diagnostics.Status.ErrorTitle="診断データの収集に失敗しました" +Diagnostics.Status.Failed="失敗" +Diagnostics.Status.FilesCollected="収集済みファイル" +Diagnostics.Status.OpenFolderQuestion="これらのファイルが保存されているフォルダを開きますか?" +Diagnostics.Status.OpenFolderTitle="フォルダを開く" +Diagnostics.Status.Output="出力" +Diagnostics.Status.Ready="準備完了" +Diagnostics.Status.Success="成功" +Diagnostics.Title="診断コレクター" Live.ChangeEvent.Failed="イベントの切り替えは5分間隔で行う必要があります。" Live.Common.Notice="お知らせ" Live.Common.StreamingInProgress="配信中のため、一時的に操作できません" Live.Create.Feature207Disabled="この地域では現在配信に対応していません" -Live.Create.GetSelfInfoFailed="配信情報の取得に失敗しました。一度ログアウトして再度ログインし直してください" +Live.Create.GetSelfInfoFailed="配信情報の取得に失敗しました。一度OBSを再起動してください" Live.Create.Title="配信に対応していません" Live.EventChange.Confirm.Cancel="キャンセル" Live.EventChange.Confirm.Confirm="確認" @@ -89,42 +109,28 @@ Live.Settings.CloseLive.Confirm.Button="配信終了" Live.Settings.Error="エラー" Live.Settings.Event="イベント" Live.Settings.Event.Tip="イベントは5分に1回変更できます" -Live.Settings.GetRoomInfoError="ルーム情報の取得に失敗しました" -Live.Settings.GetRtmpError="RTMP設定の取得に失敗しました" Live.Settings.GroupCall="Group Call" Live.Settings.GroupCall.Help.Button="OK" Live.Settings.GroupCall.Help.Content="

Group Callの開始方法

手順1. OBSで「Group Call」をオンにする

有効にすると、Group Call への参加や招待の受け取りが可能になります。

手順2. 17LIVEアプリでGroup Callを開始

OBSで配信を開始した後、17LIVEアプリでご自身の配信ルームに入り、画面右上にあるGroup Callアイコンをタップすると、Group Callが開始されます。 詳細については「よくあるご質問」をご確認ください。

手順3. エコーを防ぐためのヒント

PCからGroup Call参加中に、モバイル端末のアプリでも同じ配信ルームを開くと、モバイル端末の音声がPCに拾われ、距離によっては音声の反響(エコー)が発生する可能性があります。エコーを防ぐためには、Group Callの音声はヘッドフォンでお聞きいただくか、モバイル端末とPCとの距離を十分に離してください。また、相手が話している間は、OBS側でマイクをミュートすることも有効な対策です。

" Live.Settings.GroupCall.Help.Title="パーティー配信の開始方法" Live.Settings.GroupCall.Help.Tooltip="パーティー配信の説明を表示" -Live.Settings.GroupCall.Tip="設定をオンにすると、Group Call への参加や招待を受け取ることができます。" +Live.Settings.GroupCall.Tip="設定をオンにすると、Group Call への参加や招待を受け取ることができます。 ただし、マルチプラットフォーム配信を利用する場合は、この設定をオフにしてください。" Live.Settings.Layout="配信ルームのレイアウト" Live.Settings.Layout.Landscape="横配信" Live.Settings.Layout.Portrait="縦配信" Live.Settings.LiveCreated="17LIVEの配信準備ができました" Live.Settings.LiveCreated.Tip="17LIVEの配信準備ができました。\n「配信準備完了とストリーミング開始」をクリックすると、17LIVEの配信とOBSでのストリーミングが開始されます。\n「配信準備完了」をクリックすると配信準備は完了しますが、OBSでのストリーミングがまだ始まりません。\n「配信終了」をクリックすると配信が終了されます。" Live.Settings.LiveNotification="配信通知" -Live.Settings.LoadError="情報の読み込みに失敗しました" Live.Settings.Loading="データを読み込んでいます。しばらくお待ちください" Live.Settings.No="いいえ" -Live.Settings.Rank0.army_only_stream_level_setting_all_level="全レベル、条件に合うメンバー数" -Live.Settings.Rank0.army_only_stream_level_setting_level="すべての階級 / 参加可能人数:{subscribersAmount} 人" -Live.Settings.Rank0.army_only_stream_level_setting_top_level="{name} とそれ以上 / 参加可能人数:{subscribersAmount}人" -Live.Settings.Rank0.army_rank_name_1="{name} のみ / 参加可能人数:{subscribersAmount} 人" -Live.Settings.Rank0.army_rank_name_2="士官長" -Live.Settings.Rank0.army_rank_name_3="上尉" -Live.Settings.Rank0.army_rank_name_4="上校" -Live.Settings.Rank0.army_rank_name_5="上將" -Live.Settings.Rank1.army_only_stream_level_setting_all_level="下士" -Live.Settings.Rank1.army_only_stream_level_setting_level="すべての階級 / 参加可能人数:{subscribersAmount} 人" -Live.Settings.Rank1.army_only_stream_level_setting_top_level="階級 {value} とそれ以上 / 参加可能人数:{subscribersAmount}人" +Live.Settings.RequiredDataMissing="必要なデータが不足しています。ネットワーク接続を確認して再度お試しください。" +Live.Settings.Retry="再試行" Live.Settings.Save="設定を保存" -Live.Settings.Save.Category.Empty="配信カテゴリーを選択してください" Live.Settings.Save.Success="設定が保存されました" Live.Settings.Save.Title="設定を保存" Live.Settings.Save.Title.Empty="配信タイトルを入力してください" Live.Settings.ShowInHotPage="注目ページに配信を表示する" Live.Settings.StartLive="配信準備完了" -Live.Settings.StartLiveAndStream="配信準備完了とストリーミング開始" Live.Settings.StartLiveOnly="配信準備完了" Live.Settings.StartStreaming="ストリーミング開始" Live.Settings.StartStreaming.Tip="OBSで配信を開始しますか?" @@ -138,6 +144,7 @@ Live.Settings.UserCondition="参加条件" Live.Settings.VirtualLiver="これは「バーチャル配信」です。" Live.Settings.VirtualLiver.Tip="この設定にチェックを入れると、配信が「バーチャル配信」タブに掲載されます。※ただし当社がバーチャル配信ではないと判断した場合、タブから削除する場合がございますので予めご了承ください。" Live.Settings.VirtualLiver.Title="この配信は「バーチャル配信」ですか?" +Live.Settings.Warning="警告" Live.Settings.Yes="はい" Live.StreamList="配信設定一覧" Live.StreamList.Empty="保存された配信設定がありません" @@ -149,40 +156,84 @@ Menu.Broadcast="配信" Menu.ChatRoom="コメント" Menu.CheckUpdate="アップデート確認" Menu.CheckUpdate.Url="https://github.com/17media/obs-plugin/releases/latest" +Menu.Diagnostics="ワンクリックサポート" Menu.Dock="17LIVE配信ツール" Menu.Help="ヘルプ" Menu.Help.Url="https://jp.17.live/faq/41650/" Menu.LiveList="配信設定一覧" +Menu.PreviewDock="プレビューウィンドウ" Menu.RockZone="ロックゾーン" -Menu.Settings="設定" Menu.SignIn="ログイン" Menu.SignOut="ログアウト" +MultiRTMP.AddStream="配信を追加" +MultiRTMP.AddStream.Title="配信プラットフォームを追加" +MultiRTMP.AuthorizationFailed.Text="認証に失敗しました:%1\n\n資格情報を確認して、もう一度お試しください。" +MultiRTMP.AuthorizationFailed.Title="認証失敗" +MultiRTMP.Config.Encoder.Audio="音声エンコーダー" +MultiRTMP.Config.Encoder.Video="映像エンコーダー" +MultiRTMP.Config.Tab.Audio="音声" +MultiRTMP.Config.Tab.Output="出力" +MultiRTMP.Config.Tab.Video="映像" +MultiRTMP.Config.Title="マルチプラットフォーム配信設定" +MultiRTMP.Delete="削除" +MultiRTMP.Delete.Confirm="このプラットフォームを削除しますか?" +MultiRTMP.Delete.Title="プラットフォーム削除" +MultiRTMP.Dock.StartAll="すべて開始" +MultiRTMP.Dock.StartAll.WithCount="すべて開始 (%1)" +MultiRTMP.Dock.StopAll="すべて停止" +MultiRTMP.Dock.StopAll.WithCount="すべて停止 (%1)" +MultiRTMP.Dock.Title="マルチプラットフォーム配信一覧" +MultiRTMP.Edit="編集" +MultiRTMP.EditStream.Title="プラットフォームを編集" +MultiRTMP.Error.AddFailed="プラットフォームの追加に失敗しました" +MultiRTMP.Error.Title="マルチプラットフォーム配信エラー" +MultiRTMP.Error.UpdateFailed="プラットフォームの更新に失敗しました" +MultiRTMP.List.EmptyTip="新しい配信を作成してください" +MultiRTMP.Precheck.GroupCallNotSupported="パーティー配信(Group Call)はマルチRTMPに対応していません。現在の配信を終了してから、再度開始してください。" +MultiRTMP.Precheck.LiveNotStarted="まず17LIVEで配信を開始してから、マルチRTMPを開始してください。" +MultiRTMP.Precheck.StartObsStreamingFirst="17LIVEの配信準備は完了していますが、OBSのストリーミングが開始されていません。先にストリーミングを開始してください。" +MultiRTMP.Precheck.StreamManagerUnavailable="17LIVEのストリームサービスを利用できません。OBSを再起動して再試行してください。" +MultiRTMP.Start="開始" +MultiRTMP.Stats.Duration="接続時間" +MultiRTMP.Stats.FrameRate="フレームレート" +MultiRTMP.Stats.UploadRate="アップロード速度" +MultiRTMP.Status.Connected="接続済み" +MultiRTMP.Status.Connecting="接続中" +MultiRTMP.Status.Disconnected="切断済み" +MultiRTMP.Stop="停止" +MultiRTMP.StopAllConfirm="全てのプラットフォームの配信を停止しますか?" +MultiRTMP.Wait="お待ちください…" +MultiRtmp.Config.AdvancedSettings="詳細設定" +MultiRtmp.Config.Audio.UseOBS="OBSの「音声」設定を使用" +MultiRtmp.Config.Authorize="ログインを認証" +MultiRtmp.Config.Cancel="キャンセル" +MultiRtmp.Config.Confirm="OK" +MultiRtmp.Config.Deauthorize="認証解除" +MultiRtmp.Config.Protocol="プロトコル" +MultiRtmp.Config.StreamName="ストリーム名" +MultiRtmp.Config.Video.FPSDenominator="FPS分母" +MultiRtmp.Config.Video.OutputScene="出力シーン" +MultiRtmp.Config.Video.Resolution="解像度" +MultiRtmp.Config.Video.UseOBS="OBSの「映像」設定を使用" +PreviewDock.Tip.AnimationOnly="ギフトアニメーションのプレビュー専用で、配信は行われません。横画面配信の場合、リスナーにはアニメーションが表示されません。" +PreviewDock.Title="プレビューウィンドウ" RockZone.Badge.Army.Template="階級 %1 (%2)" RockZone.Badge.Guardian="ガーディアン" -RockZone.Badge.TopContributor="トップギフター" -RockZone.EmptyList="まだギフトは贈られていません" +RockZone.Badge.TopContributor="ギフトトップ" +RockZone.EmptyList="現在、ギフトを贈っている視聴者はいません" RockZone.Followers="フォロワー" RockZone.Following="フォロー中" RockZone.Hint="リストには、上位50名のユーザーのみ表示されます。" RockZone.Likes="いいね" -RockZone.Loading="リスナーを読込中…" RockZone.PokeAll="全員にPoke" -RockZone.PokeAll.Success="リスナー全員にPokeしました。" RockZone.PokeUser="Poke" -RockZone.PokeUser.Success="このリスナーをPokeしました。" RockZone.Title="ロックエリア" -RockZone.UserCard.followers="フォロワー" -RockZone.UserCard.following="フォロ中" -RockZone.UserCard.likes="いいね" Update.Cancel="キャンセル" -Update.CheckFailed="アップデートの確認に失敗しました" -Update.CheckFailed.Message="アップデートを確認できませんでした:%1" Update.DownloadComplete="ダウンロード完了" Update.DownloadComplete.Message="アップデートパッケージが次の場所にダウンロードされました:%1\n\nダウンロードしたファイルを手動でインストールしてください。" Update.DownloadFailed="ダウンロードに失敗しました" Update.DownloadFailed.NetworkError="ダウンロードに失敗しました:%1" Update.DownloadFailed.NoPackage="お使いのシステムに適したインストーラが見つかりません。" -Update.DownloadFailed.SaveError="ダウンロードしたファイルを保存できませんでした。" Update.DownloadProgress="アップデートパッケージをダウンロード中... %1/%2 MB" Update.Downloading="アップデートパッケージをダウンロード中..." Update.FileExists="ファイルが既に存在します" @@ -190,6 +241,52 @@ Update.FileExists.Message="ファイル %1 は既に存在します。\n\n再度 Update.InstallReminder="インストールの確認" Update.InstallReminder.Message="ダウンロードフォルダ内の %1 を手動でインストールしてください" Update.NewVersionFound="新しいバージョンが見つかりました" -Update.NewVersionFound.Message="17Live OBS プラグインの新しいバージョン %1 が見つかりました。\n\nダウンロードしてインストールしますか?" -Update.NewVersionFound.No="いいえ" -Update.NewVersionFound.Yes="はい" +Update.NewVersionFound.Message="17LIVE OBS プラグインの新しいバージョン %1 が見つかりました。\n\nダウンロードしてインストールしますか?" + +MultiRTMP.ErrTitle.MissingServerKey="設定不足" +MultiRTMP.ErrDesc.MissingServerKey="サーバーURLまたはキーが空です" +MultiRTMP.ErrSolution.MissingServerKey="設定を確認し配信情報を再取得" + +MultiRTMP.ErrTitle.UnknownPlatform="不明なプラットフォーム" +MultiRTMP.ErrDesc.UnknownPlatform="配信プラットフォームを認識できません" +MultiRTMP.ErrSolution.UnknownPlatform="プラットフォームを変更または設定を確認" + +MultiRTMP.ErrTitle.AuthInvalid="認証無効" +MultiRTMP.ErrDesc.AuthInvalid="ログインまたはトークンが無効" +MultiRTMP.ErrSolution.AuthInvalid="再ログインまたはトークン更新" + +MultiRTMP.ErrTitle.StartFailed="開始失敗" +MultiRTMP.ErrDesc.StartFailed="OBS出力の開始に失敗" +MultiRTMP.ErrSolution.StartFailed="エンコーダ設定と配信情報を確認" + +MultiRTMP.ErrTitle.ConnectFailed="接続失敗" +MultiRTMP.ErrDesc.ConnectFailed="ネットワーク接続またはハンドシェイク失敗" +MultiRTMP.ErrSolution.ConnectFailed="ネットワークとサーバーURLを確認" + +MultiRTMP.ErrTitle.APIError="APIエラー" +MultiRTMP.ErrDesc.APIError="プラットフォームAPIがエラー返却" +MultiRTMP.ErrSolution.APIError="後で再試行またはサポートへ連絡" + +MultiRTMP.ErrTitle.Generic="開始失敗" +MultiRTMP.ErrDesc.Generic="不明なエラーが発生" +MultiRTMP.ErrSolution.Generic="ネットワークと設定を確認し必要なら支援へ連絡" +MultiRtmp.Config.Reauthorize="認可済み—クリックで再認可" +MultiRtmp.Config.Deauthorize="認証解除" +MultiRTMP.ErrTitle.NetworkError="ネット異常" +MultiRTMP.ErrDesc.NetworkError="ネット接続またはハンドシェイク失敗" +MultiRTMP.ErrSolution.NetworkError="ネット/プロキシ/ファイアウォール/DNSと時刻を確認し再試行や回線変更" + +MultiRTMP.CloseLive.InfoTip="この操作は他のプラットフォームの配信を終了しません" +MultiRTMP.Stop.InfoTip17LIVE="この操作は17LIVEプラットフォームの配信を終了しません" + +Live.Settings.AutoRes.Title="解像度の自動設定" +Live.Settings.AutoRes.Msg.Landscape="横向き配信が選択されました。OBSの基本解像度と出力解像度を1280x720に設定します。\n\n配信開始前にOBSの設定で手動で調整・上書きすることも可能です。" +Live.Settings.AutoRes.Msg.Portrait="縦向き配信が選択されました。OBSの基本解像度と出力解像度を720x1280に設定します。\n\n配信開始前にOBSの設定で手動で調整・上書きすることも可能です。" +PreviewDock.LoadingGifts="アニメーション読み込み中..." +PreviewDock.Initializing="ビデオ表示を初期化中..." +ChatRoom.LoadingGifts="17LIVEギフトデータを読み込んでいます..." +Live.Create.Failed="配信の作成に失敗しました" + +Api.Error.39="配信を始めるには、電話番号認証が必要です。" +Api.Error.35="ただいま配信できません。プロフィール設定と画像をご確認ください。" +Api.Error.Generic="ただいま配信できません。カスタマーサポートにお問いわせください。 (%1)" diff --git a/data/locale/zh-CN.ini b/data/locale/zh-CN.ini index fb5af64..dcee448 100644 --- a/data/locale/zh-CN.ini +++ b/data/locale/zh-CN.ini @@ -1,195 +1,292 @@ -# OBS 17Live Plugin Locale Keys - zh-TW -# Updated from Excel on 2025-09-17 17:22:40 -# Please review the translations for accuracy +; OBS 17LIVE Plugin Language File +; Generated automatically by generate_obs_locale.py on 2025-11-27T22:27:47.373877 -17Live="17Live" -Auth.Caption="17LIVE ID 登入" -Auth.Error01="用戶名或密碼不正確" -Auth.Error02="登入失敗:%1" -Auth.ForgotPassword="忘記密碼?" -Auth.Help="更多登入幫助" -Auth.Hint01="請提高警覺:17LIVE 不會以任何分期付款失敗等名義要求您提供帳戶資訊、至ATM操作或提供信用卡等資料。" -Auth.LoginSuccess="登入成功" -Auth.LoginSuccess.Tip="歡迎來到17Live,%1!" -Auth.Password="密碼" -Auth.Password.Placeholder="********" -Auth.Password.Tip="若您已驗證電話號碼,您可點擊右側「忘記密碼」來獲得新密碼進行17帳號登入,或點選下方「更多登入幫助」,連結到說明文章。" -Auth.Register="註冊新帳號" -Auth.SignIn="登入" -Auth.Username="帳號" -ChatRoom.Title="留言" -CustomEvent.Cancel="取消" -CustomEvent.Close="關閉" -CustomEvent.Confirm.Close.Title="關閉自訂活動提醒" -CustomEvent.Confirm.Close.Yes="確認關閉活動" -CustomEvent.Confirm.CloseEvent="確定要關閉活動嗎?關閉後活動內容與結果將不再顯示。" -CustomEvent.Confirm.Stop.Title="停止自訂活動提醒" -CustomEvent.Confirm.Stop.Yes="確定停止" -CustomEvent.Confirm.StopEvent="確定要停止活動嗎?停止後活動即結束並顯示結果。" -CustomEvent.Create="建立活動" -CustomEvent.DailyGoal="每日目標(寶寶幣)" +17LIVE="17LIVE" +Auth.Caption="17LIVE 帐号登录" +Auth.Error01="帐号或密码错误" +Auth.Error02="登录失败: %1" +Auth.ForgotPassword="忘记密码?" +Auth.Help="更多登录帮助" +Auth.Hint01="请注意:17LIVE 绝不会以分期付款失败等理由,要求您提供帐户信息、操作 ATM 或提供信用卡详细资料。" +Auth.LoginSuccess="登录成功" +Auth.LoginSuccess.Tip="欢迎来到 17LIVE,%1!" +Auth.Password="密码" +Auth.Password.Tip="若您已验证电话号码,可点击右侧「忘记密码」取得新密码,并使用 17 帐号登录,或点击下方「更多登录帮助」查看说明文章。" +Auth.Register="注册新帐号" +Auth.SignIn="登录" +ChatRoom.Title="留言视窗" +CustomEvent.Close="关闭" +CustomEvent.Confirm.Close.Title="关闭自订活动确认" +CustomEvent.Confirm.Close.Yes="确认关闭" +CustomEvent.Confirm.CloseEvent="确定要关闭活动吗?关闭后将不再显示活动内容与结果" +CustomEvent.Confirm.Stop.Title="停止自订活动提醒" +CustomEvent.Confirm.Stop.Yes="确认停止" +CustomEvent.Confirm.StopEvent="确定要停止活动吗?活动将结束并显示结果。" +CustomEvent.Create="建立活动" +CustomEvent.DailyGoal="每日目标(宝宝币)" CustomEvent.DailyGoal.Placeholder="666666" -CustomEvent.Description="活動描述" -CustomEvent.Description.Placeholder="例:前三名可以要求主播唱生日快樂歌!" -CustomEvent.Dialog.Title="自訂活動 (選填)" -CustomEvent.EndDate="活動結束日期(至多30天)" +CustomEvent.Description="活动说明" +CustomEvent.Description.Placeholder="范例:送礼前三名可以点歌唱生日快乐歌!" +CustomEvent.Dialog.Title="建立你的专属活动" +CustomEvent.EndDate="活动结束日期(最长30天)" CustomEvent.Error="提示" -CustomEvent.Error.CloseFailed="關閉活動失敗" -CustomEvent.Error.CreateFailed="建立活動失敗" -CustomEvent.Error.DescriptionTooLong="活動描述不能超過200個字符" -CustomEvent.Error.EmptyDescription="請輸入活動描述" -CustomEvent.Error.EmptyTitle="活動標題不能為空" -CustomEvent.Error.GiftNotSelected="請選擇活動禮物" -CustomEvent.Error.MaxGifts="最多只能選擇 %1 個禮物" -CustomEvent.Error.NoGifts="必須選擇禮物" -CustomEvent.Error.StopFailed="中止活動失敗" -CustomEvent.Error.Title="錯誤" -CustomEvent.Error.TitleEmpty="請輸入活動標題" -CustomEvent.Gifts="活動禮物" -CustomEvent.LoadingGifts="正在載入禮物..." -CustomEvent.SelectedGifts.Placeholder="選擇禮物" +CustomEvent.Error.CloseFailed="关闭活动失败" +CustomEvent.Error.CreateFailed="建立活动失败" +CustomEvent.Error.DescriptionTooLong="活动说明不能超过200字" +CustomEvent.Error.EmptyDescription="请输入活动说明" +CustomEvent.Error.EmptyTitle="活动标题不能为空" +CustomEvent.Error.MaxGifts="最多只能选择%1个礼物" +CustomEvent.Error.NoGifts="必须至少选择一个礼物" +CustomEvent.Error.StopFailed="停止活动失败" +CustomEvent.Gifts="活动礼物" +CustomEvent.LoadingGifts="正在读取礼物..." +CustomEvent.SelectedGifts.Placeholder="选择礼物" CustomEvent.Stop="停止" -CustomEvent.Success="成功" -CustomEvent.Success.Created="自訂活動創建成功!" -CustomEvent.Title="活動標題" -CustomEvent.Title.Placeholder="例:一起來慶祝我的生日吧!" -CustomEvent.TotalGoal="總體目標(寶寶幣)" +CustomEvent.Title="活动标题" +CustomEvent.Title.Placeholder="范例:一起来庆生吧!" +CustomEvent.TotalGoal="总目标(宝宝币)" CustomEvent.TotalGoal.Placeholder="88888888" -Error.Confirm="確定" -Live.ChangeEvent.Failed="切換活動需間隔5分鐘。" +Diagnostics.Browse="浏览…" +Diagnostics.Cancel="取消" +Diagnostics.Categories.ConfigSnapshot="设定快照" +Diagnostics.Categories.CrashInfo="崩溃信息" +Diagnostics.Categories.NetworkLogs="网络记录" +Diagnostics.Categories.NetworkRequests="网络请求" +Diagnostics.Categories.OBSLogs="OBS 记录" +Diagnostics.Categories.PluginLogs="插件记录" +Diagnostics.Categories.PrivacyFilter="隐私过滤" +Diagnostics.Categories.SystemInfo="系统信息" +Diagnostics.Categories.Title="类别" +Diagnostics.Collect="收集" +Diagnostics.Description="此工具收集诊断信息以协助排除问题。所有收集的资料仅储存在本地,不会自动上传。" +Diagnostics.OutputPath="输出路径:" +Diagnostics.Privacy.WarningMessage="警告:部分诊断资料可能包含个人信息。分享前请仔细检查内容。" +Diagnostics.Privacy.WarningTitle="隐私警告" +Diagnostics.SavePackage="储存套件" +Diagnostics.Status.Collecting="正在收集…" +Diagnostics.Status.CollectorFailed="建立诊断收集器失败" +Diagnostics.Status.Error="错误" +Diagnostics.Status.ErrorMessage="建立诊断套件失败" +Diagnostics.Status.ErrorTitle="诊断收集失败" +Diagnostics.Status.Failed="失败" +Diagnostics.Status.FilesCollected="已收集档案" +Diagnostics.Status.OpenFolderQuestion="是否要开启包含这些档案的资料夹?" +Diagnostics.Status.OpenFolderTitle="开启资料夹" +Diagnostics.Status.Output="输出" +Diagnostics.Status.Ready="就绪" +Diagnostics.Status.Success="成功" +Diagnostics.Title="诊断收集器" +Live.ChangeEvent.Failed="更换活动需要间隔5分钟。" Live.Common.Notice="提示" -Live.Common.StreamingInProgress="正在直播中,暫時不能操作" -Live.Create.Feature207Disabled="此區域暫不支援開播" -Live.Create.GetSelfInfoFailed="獲得開播資訊失敗,請登出然後重新登入後再試" -Live.Create.Title="不支援開播" +Live.Common.StreamingInProgress="直播中,暂时无法操作" +Live.Create.Feature207Disabled="此地区暂不支援开播" +Live.Create.GetSelfInfoFailed="獲取開播資訊失敗,請重啟OBS" +Live.Create.Title="不支援开播" Live.EventChange.Confirm.Cancel="取消" -Live.EventChange.Confirm.Confirm="確認" -Live.EventChange.Confirm.Message="將活動切換為 %1?\n\n5秒後將會切換至新活動。" -Live.EventChange.Confirm.Title="切換活動" -Live.EventChange.CoolDown="活動切換冷卻時間:%1:%2" -Live.Settings="設定" +Live.EventChange.Confirm.Confirm="确认" +Live.EventChange.Confirm.Message="是否切换活动为 %1?\n\n5秒后将切换为新活动。" +Live.EventChange.Confirm.Title="切换活动" +Live.EventChange.CoolDown="更换活动冷却中:%1:%2" +Live.Settings="开播设定" Live.Settings.AddTag="新增" -Live.Settings.Archive.AutoPublish="自動發布預覽" -Live.Settings.Archive.AutoPublish.Tip="自動以影片的方式發佈在個人頁。您可以前往17LIVE App編輯該部典藏的相關內容(如標題、標籤等)" -Live.Settings.Archive.ClipPermission="允許直播剪輯身份" -Live.Settings.Archive.ClipPermission.Tip="有權限的觀眾可以在你直播時剪輯直播片段,並分享出去。" -Live.Settings.Archive.Record="典藏直播" -Live.Settings.Archive.Record.Tip="儲存直播內容7天,並且只有您本人可以觀看。\n(限制:不超過8小時,PK/群聊內容皆不支持。)" -Live.Settings.ArmyOnly="戰隊限定觀看" -Live.Settings.ArmyOnly.Tip="您的戰隊等級需要高於1,才能使用戰隊限定開播喔!加油~" +Live.Settings.Archive.AutoPublish="自动发布直播预览" +Live.Settings.Archive.AutoPublish.Tip="直播结束后自动发布到个人页面。 您可以前往 17LIVE App 管理您的直播存档信息,例如标题和标签。" +Live.Settings.Archive.ClipPermission="允许剪辑精华" +Live.Settings.Archive.ClipPermission.Tip="允许特定观众剪辑您的直播片段并分享。" +Live.Settings.Archive.Record="留存直播回放" +Live.Settings.Archive.Record.Tip="直播存档将保留 7 天,仅供您本人观看。\n(限制:最长 8 小时;不支援 PK/派对直播。)" +Live.Settings.ArmyOnly="军队限定观看" +Live.Settings.ArmyOnly.Tip="你的军队等级必须大于 1 才能使用军队限定直播功能。继续加油吧!" Live.Settings.BroadcastMode="直播模式" -Live.Settings.Category="類別" -Live.Settings.CloseLive="關閉直播" +Live.Settings.Category="分类" +Live.Settings.CloseLive="关播" Live.Settings.CloseLive.Auto.Cancel="取消" -Live.Settings.CloseLive.Auto.Confirm="確認關閉" -Live.Settings.CloseLive.Auto.Message="直播狀態異常檢測,已連續%1次檢測失敗。\n\n可能原因:\n• 網絡連線不穩定\n• 伺服器回應異常\n• 本地網絡環境變化\n\n要結束直播嗎?" -Live.Settings.CloseLive.Auto.Title="直播自動關閉確認" -Live.Settings.CloseLive.Confirm="確定要關播嗎?" -Live.Settings.CloseLive.Confirm.Button="確定關播" -Live.Settings.Error="錯誤" -Live.Settings.Event="活動" -Live.Settings.Event.Tip="每隔5分鐘可以切換一次活動" -Live.Settings.GetRoomInfoError="獲取 Room Info 錯誤" -Live.Settings.GetRtmpError="獲取 RTMP 設定錯誤" -Live.Settings.GroupCall="派對直播" -Live.Settings.GroupCall.Help.Button="了解" -Live.Settings.GroupCall.Help.Content="

如何開啟派對直播

1. 在 OBS 開啟「派對直播(Group Call)」

啟用後即可加入或接收派對直播邀請。

2. 直播後於17LIVE App內進行派對直播

在OBS開啟直播後,利用手機開啟17LIVE App開啟直播間進入『主播遙控器』,點擊右上方派對按鈕即可開啟派對直播。詳情可至常見問題查看更多。

3. 避免回音問題

為了避免主播控制器的聲音被電腦麥克風收錄,造成回音問題,請使用耳機收聽派對內容,或讓保持主播控制器與電腦麥克風距離。也可在對方講話時,在OBS內靜音電腦麥克風。

" -Live.Settings.GroupCall.Help.Title="如何開啟派對直播" -Live.Settings.GroupCall.Help.Tooltip="點擊查看派對直播說明" -Live.Settings.GroupCall.Tip="開啟此設定才有機會加入或是接收派對直播的邀請。" -ßLive.Settings.Layout="開播格式" -Live.Settings.Layout.Landscape="直式播出" -Live.Settings.Layout.Portrait="橫式播出" -Live.Settings.LiveCreated="17Live 直播已建立" -Live.Settings.LiveCreated.Tip="17Live 直播已建立,點擊「開始直播」按鈕僅開始直播;點擊「關閉直播」按鈕可關閉直播。" -Live.Settings.LiveNotification="開播通知" -Live.Settings.LoadError="加載直播間資訊失敗" -Live.Settings.Loading="正在加載直播間資料,請稍候..." +Live.Settings.CloseLive.Auto.Confirm="确认关播" +Live.Settings.CloseLive.Auto.Message="侦测到直播状态异常,已连续 %1 次检查失败。\n\n可能原因:\n• 网络连线不稳定\n• 服务器回应异常\n• 本地网络环境变动\n\n是否要结束直播?" +Live.Settings.CloseLive.Auto.Title="自动关播确认" +Live.Settings.CloseLive.Confirm="确定要结束直播吗?" +Live.Settings.CloseLive.Confirm.Button="结束直播" +Live.Settings.Error="错误" +Live.Settings.Event="活动" +Live.Settings.Event.Tip="每5分钟可更换一次活动" +Live.Settings.GroupCall="派对直播" +Live.Settings.GroupCall.Help.Button="知道了" +Live.Settings.GroupCall.Help.Content="

如何启用派对直播

1. 在 OBS 开启「派对直播」

启用后,您可以加入或接收派对直播的邀请。

2. 在 17LIVE App 设定派对直播

在 OBS 开始直播后,进入 17LIVE App 的「遥控器」,点击右上角的按钮开始派对直播。详细信息请参考 常见问题

3. 避免回音问题

为避免回音,请使用耳机收听遥控器上的派对直播语音,或将遥控器远离电脑麦克风。您也可以在对方说话时,在 OBS 中将麦克风静音。

" +Live.Settings.GroupCall.Help.Title="如何开始派对直播?" +Live.Settings.GroupCall.Help.Tooltip="点击查看派对直播说明" +Live.Settings.GroupCall.Tip="开启此设定以加入或接收派对直播邀请。若要使用多平台转播,请关闭此设定。" +Live.Settings.Layout="直播间配置" +Live.Settings.Layout.Landscape="横式直播" +Live.Settings.Layout.Portrait="直式直播" +Live.Settings.LiveCreated="17LIVE 直播场次已建立" +Live.Settings.LiveCreated.Tip="17LIVE 直播场次已建立。\n点击「开始直播并串流」将启动场次并开始 OBS 推流;\n点击「仅开始直播」将启动场次但暂不推流;\n点击「关闭直播」将结束场次。" +Live.Settings.LiveNotification="直播通知" +Live.Settings.Loading="正在读取直播间资料,请稍候..." Live.Settings.No="否" -Live.Settings.Rank0.army_only_stream_level_setting_all_level="全部階級,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_level="{name} 及以上,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_top_level="僅 {name},符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_rank_name_1="士官長" -Live.Settings.Rank0.army_rank_name_2="上尉" -Live.Settings.Rank0.army_rank_name_3="上校" -Live.Settings.Rank0.army_rank_name_4="上將" -Live.Settings.Rank0.army_rank_name_5="下士" -Live.Settings.Rank1.army_only_stream_level_setting_all_level="全部階級,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_level="等級 {value} 及以上,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_top_level="僅等級 {value},符合條件成員數:{subscribersAmount}" -Live.Settings.Save="儲存設定" -Live.Settings.Save.Category.Empty="請選擇直播類別" -Live.Settings.Save.Success="設定已成功儲存" -Live.Settings.Save.Title="儲存設定" -Live.Settings.Save.Title.Empty="請輸入直播標題" -Live.Settings.ShowInHotPage="顯示在熱門頁" -Live.Settings.StartLive="開始直播" -Live.Settings.StartLiveAndStream="開始直播並串流" -Live.Settings.StartLiveOnly="開始直播" -Live.Settings.StartStreaming="開始串流" -Live.Settings.StartStreaming.Tip="是否同時開始串流" -Live.Settings.StopLive="停止直播" -Live.Settings.Tags="標籤" -Live.Settings.Tags.LengthError="標籤最大為 24 個字元" -Live.Settings.Tags.Placeholder="按下Enter鍵隔開標籤" -Live.Settings.Title="標題 (必填)" -Live.Settings.Title.Placeholder="給直播間一個標題吧!" -Live.Settings.UserCondition="用戶條件" -Live.Settings.VirtualLiver="是,我是虛擬主播。" -Live.Settings.VirtualLiver.Tip="若你是虛擬主播請勾選,用戶就能更輕易地找到你的直播。" -Live.Settings.VirtualLiver.Title="你是虛擬主播嗎?" +Live.Settings.RequiredDataMissing="缺少必要资料。请检查您的网络连线并重试。" +Live.Settings.Retry="重试" +Live.Settings.Save="储存设定" +Live.Settings.Save.Success="设定已储存" +Live.Settings.Save.Title="储存设定" +Live.Settings.Save.Title.Empty="请输入直播标题" +Live.Settings.ShowInHotPage="显示在热门页面" +Live.Settings.StartLive="开始直播" +Live.Settings.StartLiveOnly="仅开始直播" +Live.Settings.StartStreaming="开始串流" +Live.Settings.StartStreaming.Tip="是否同时开始串流?" +Live.Settings.StopLive="结束直播" +Live.Settings.Tags="标签" +Live.Settings.Tags.LengthError="标签最多24个字元" +Live.Settings.Tags.Placeholder="按 Enter 分隔标签" +Live.Settings.Title="标题 (必填)" +Live.Settings.Title.Placeholder="给直播取个标题吧!" +Live.Settings.UserCondition="用户条件" +Live.Settings.VirtualLiver="是的,我是虚拟主播。" +Live.Settings.VirtualLiver.Tip="如果您是虚拟主播,请勾选此框,以便用户更容易找到您的直播。" +Live.Settings.VirtualLiver.Title="您是虚拟主播吗?" +Live.Settings.Warning="警告" Live.Settings.Yes="是" -Live.StreamList="直播列表" -Live.StreamList.Empty="當前沒有直播信息記錄,到'開播設定'中設置" +Live.StreamList="直播场次列表" +Live.StreamList.Empty="没有直播场次记录。请前往「开播设定」进行设定。" Logout.Warning.Button.No="否" Logout.Warning.Button.Yes="是" -Logout.Warning.Message="登出會中斷直播,確定嗎?" +Logout.Warning.Message="登出将会中断直播,确定要登出吗?" Logout.Warning.Title="提示" -Menu.Broadcast="開始直播" +Menu.Broadcast="开播" Menu.ChatRoom="留言" -Menu.CheckUpdate="檢查更新" +Menu.CheckUpdate="检查更新" Menu.CheckUpdate.Url="https://github.com/17media/obs-plugin/releases/latest" -Menu.Dock="17LIVE 功能塢" -Menu.Help="幫助" -Menu.Help.Url="https://17mediahelp.zendesk.com/hc/en-us/articles/31066637227161-OBS%E7%9B%B4%E6%92%AD" -Menu.LiveList="直播列表" +Menu.Diagnostics="一键支援" +Menu.Dock="17LIVE 控制面板" +Menu.Help="帮助" +Menu.Help.Url="https://17mediahelp.zendesk.com/hc/en-us/articles/31079492940313-Stream-with-OBS" +Menu.LiveList="场次列表" +Menu.PreviewDock="预览视窗" Menu.RockZone="摇滚区" -Menu.Settings="設定" -Menu.SignIn="登入" +Menu.SignIn="登录" Menu.SignOut="登出" +MultiRTMP.AddStream="新增推流" +MultiRTMP.AddStream.Title="新增推流平台" +MultiRTMP.AuthorizationFailed.Text="授权失败:%1\n\n请检查您的凭证并重试。" +MultiRTMP.AuthorizationFailed.Title="授权失败" +MultiRTMP.Config.Encoder.Audio="音讯编码器" +MultiRTMP.Config.Encoder.Video="视讯编码器" +MultiRTMP.Config.Tab.Audio="音讯" +MultiRTMP.Config.Tab.Output="输出" +MultiRTMP.Config.Tab.Video="视讯" +MultiRTMP.Config.Title="多平台推流设定" +MultiRTMP.Delete="删除" +MultiRTMP.Delete.Confirm="确定要删除此平台吗?" +MultiRTMP.Delete.Title="删除平台" +MultiRTMP.Dock.StartAll="全部开始" +MultiRTMP.Dock.StartAll.WithCount="全部开始 (%1)" +MultiRTMP.Dock.StopAll="全部停止" +MultiRTMP.Dock.StopAll.WithCount="全部停止 (%1)" +MultiRTMP.Dock.Title="多平台推流" +MultiRTMP.Edit="编辑" +MultiRTMP.EditStream.Title="编辑平台" +MultiRTMP.Error.AddFailed="新增平台失败" +MultiRTMP.Error.Title="多平台推流错误" +MultiRTMP.Error.UpdateFailed="更新平台失败" +MultiRTMP.List.EmptyTip="请建立一个新的推流" +MultiRTMP.Precheck.GroupCallNotSupported="派对直播(Group Call)不支援多平台推流。请结束目前直播后重新开始。" +MultiRTMP.Precheck.LiveNotStarted="请先开始 17LIVE 直播,再启动多平台推流。" +MultiRTMP.Precheck.StartObsStreamingFirst="17LIVE 直播已开始但 OBS 尚未推流。请先开始推流。" +MultiRTMP.Precheck.StreamManagerUnavailable="17LIVE 串流服务无法使用。请重启 OBS 再试一次。" +MultiRTMP.Start="开始" +MultiRTMP.Stats.Duration="连线时间" +MultiRTMP.Stats.FrameRate="帧率" +MultiRTMP.Stats.UploadRate="上传速率" +MultiRTMP.Status.Connected="已连线" +MultiRTMP.Status.Connecting="连线中" +MultiRTMP.Status.Disconnected="已断线" +MultiRTMP.Stop="停止" +MultiRTMP.StopAllConfirm="确定要停止所有平台的推流吗?" +MultiRTMP.Wait="请稍候…" +MultiRtmp.Config.AdvancedSettings="进阶设定" +MultiRtmp.Config.Audio.UseOBS="使用 OBS 音讯设定" +MultiRtmp.Config.Authorize="授权登录" +MultiRtmp.Config.Cancel="取消" +MultiRtmp.Config.Confirm="确定" +MultiRtmp.Config.Deauthorize="解除授权" +MultiRtmp.Config.Protocol="协定" +MultiRtmp.Config.StreamName="串流名称" +MultiRtmp.Config.Video.FPSDenominator="FPS 分母" +MultiRtmp.Config.Video.OutputScene="输出来源" +MultiRtmp.Config.Video.Resolution="解析度" +MultiRtmp.Config.Video.UseOBS="使用 OBS 视讯设定" +PreviewDock.Tip.AnimationOnly="此视窗仅供预览礼物动画,不会播出。横式直播时听众端不会显示礼物动画。" +PreviewDock.Title="预览视窗" RockZone.Badge.Army.Template="階級 %1 (%2)" RockZone.Badge.Guardian="守護騎士" RockZone.Badge.TopContributor="本次送禮最多" -RockZone.EmptyList="目前尚無送禮觀眾" -RockZone.Followers="粉絲數" -RockZone.Following="追蹤中" -RockZone.Hint="僅顯示前 50 名用戶在名單上。" -RockZone.Likes="愛心數" -RockZone.Loading="正在載入用戶⋯" -RockZone.PokeAll="戳所有朋友" -RockZone.PokeAll.Success="已成功戳了所有用戶。" -RockZone.PokeUser="戳戳TA" -RockZone.PokeUser.Success="已成功戳了該用戶。" -RockZone.Title="搖滾區" -RockZone.UserCard.followers="粉絲數" -RockZone.UserCard.following="追蹤中" -RockZone.UserCard.likes="愛心數" +RockZone.EmptyList="目前没有观众送礼。" +RockZone.Followers="粉丝" +RockZone.Following="关注中" +RockZone.Hint="列表中仅显示前 50 名用户。" +RockZone.Likes="按赞" +RockZone.PokeAll="戳全部好友" +RockZone.PokeUser="戳他" +RockZone.Title="摇滚区" Update.Cancel="取消" -Update.CheckFailed="檢查更新失敗" -Update.CheckFailed.Message="無法檢查更新:%1" -Update.DownloadComplete="下載完成" -Update.DownloadComplete.Message="更新檔已下載到:%1\n\n請手動安裝下載的文件。" -Update.DownloadFailed="下載失敗" -Update.DownloadFailed.NetworkError="下載失敗:%1" -Update.DownloadFailed.NoPackage="未找到適合當前系統的安裝檔。" -Update.DownloadFailed.SaveError="無法保存下載的文件。" -Update.DownloadProgress="正在下載更新檔⋯%1/%2 MB" -Update.Downloading="正在下載更新檔⋯" -Update.FileExists="文件已存在" -Update.FileExists.Message="文件 %1 已存在。\n\n是否要重新下載?" -Update.InstallReminder="安裝提醒" -Update.InstallReminder.Message="請手動安裝下載文件夾中的 %1" -Update.NewVersionFound="發現新版本" -Update.NewVersionFound.Message="17LIVE OBS擴充套件有新版本 %1 ,\n是否下載並安裝更新?" -Update.NewVersionFound.No="否" -Update.NewVersionFound.Yes="是" +Update.DownloadComplete="下载完成" +Update.DownloadComplete.Message="更新档已下载至:%1\n\n请手动安装下载的档案。" +Update.DownloadFailed="下载失败" +Update.DownloadFailed.NetworkError="下载失败:%1" +Update.DownloadFailed.NoPackage="找不到适合您系统的安装程式。" +Update.DownloadProgress="正在下载更新档... %1/%2 MB" +Update.Downloading="正在下载更新档..." +Update.FileExists="档案已存在" +Update.FileExists.Message="档案 %1 已存在。\n\n要重新下载吗?" +Update.InstallReminder="安装提醒" +Update.InstallReminder.Message="请手动安装下载资料夹中的 %1" +Update.NewVersionFound="发现新版本" +Update.NewVersionFound.Message="发现 17LIVE OBS 插件的新版本 (%1)。\n\n要下载并安装更新吗?" + +MultiRTMP.ErrTitle.MissingServerKey="参数缺失" +MultiRTMP.ErrDesc.MissingServerKey="服务器 URL 或金钥为空" +MultiRTMP.ErrSolution.MissingServerKey="请检查设定或重新获取串流信息" + +MultiRTMP.ErrTitle.UnknownPlatform="未知平台" +MultiRTMP.ErrDesc.UnknownPlatform="无法识别的串流平台" +MultiRTMP.ErrSolution.UnknownPlatform="请更换平台或检查平台设定" + +MultiRTMP.ErrTitle.AuthInvalid="验证无效" +MultiRTMP.ErrDesc.AuthInvalid="登录或令牌无效" +MultiRTMP.ErrSolution.AuthInvalid="请重新登录或重新整理令牌" + +MultiRTMP.ErrTitle.StartFailed="启动失败" +MultiRTMP.ErrDesc.StartFailed="OBS 输出启动错误" +MultiRTMP.ErrSolution.StartFailed="请检查编码器设定和串流参数" + +MultiRTMP.ErrTitle.ConnectFailed="连线失败" +MultiRTMP.ErrDesc.ConnectFailed="网络连线或握手失败" +MultiRTMP.ErrSolution.ConnectFailed="请检查网络和服务器 URL" + +MultiRTMP.ErrTitle.APIError="API 错误" +MultiRTMP.ErrDesc.APIError="平台 API 返回错误" +MultiRTMP.ErrSolution.APIError="请稍后重试或联系支援" + +MultiRTMP.ErrTitle.Generic="启动失败" +MultiRTMP.ErrDesc.Generic="发生未知错误" +MultiRTMP.ErrSolution.Generic="请检查网络和设定;如需协助请联系支援" +MultiRtmp.Config.Reauthorize="已授权 — 点击重新授权" +MultiRtmp.Config.Deauthorize="解除授权" +MultiRTMP.ErrTitle.NetworkError="网络错误" +MultiRTMP.ErrDesc.NetworkError="网络连线或握手失败" +MultiRTMP.ErrSolution.NetworkError="检查网络/代理/防火墙/DNS及时间;重试或切换网络" + +MultiRTMP.CloseLive.InfoTip="此操作不會關播其他平台" +MultiRTMP.Stop.InfoTip17LIVE="此操作不會關閉17LIVE平台的直播" + +Live.Settings.AutoRes.Title="自动设定解析度" +Live.Settings.AutoRes.Msg.Landscape="已选择横向模式。OBS 基础解析度和输出解析度将设定为 1280x720。\n\n您可以在直播开始前,于 OBS 设定中手动调整并覆盖这些设定。" +Live.Settings.AutoRes.Msg.Portrait="已选择直向模式。OBS 基础解析度和输出解析度将设定为 720x1280。\n\n您可以在直播开始前,于 OBS 设定中手动调整并覆盖这些设定。" +PreviewDock.LoadingGifts="動畫載入中..." +PreviewDock.Initializing="正在初始化視訊顯示..." +ChatRoom.LoadingGifts="17LIVE禮物數據加載中..." +Live.Create.Failed="創建直播失敗" + +Api.Error.39="請先完成電話認證" +Api.Error.35="目前無法開播,請先確認您的帳號資訊及大頭貼。" +Api.Error.Generic="目前無法開播,請聯繫客服尋求協助。(%1)" diff --git a/data/locale/zh-TW.ini b/data/locale/zh-TW.ini index 7b0b81c..7aaccba 100644 --- a/data/locale/zh-TW.ini +++ b/data/locale/zh-TW.ini @@ -1,195 +1,292 @@ -# OBS 17Live Plugin Locale Keys - zh-TW -# Updated from Excel on 2025-09-17 17:22:40 -# Please review the translations for accuracy +; OBS 17LIVE Plugin Language File +; Generated automatically by generate_obs_locale.py on 2025-11-27T22:27:47.373877 -17Live="17Live" -Auth.Caption="17LIVE ID 登入" -Auth.Error01="用戶名或密碼不正確" -Auth.Error02="登入失敗:%1" +17LIVE="17LIVE" +Auth.Caption="17LIVE 帳號登入" +Auth.Error01="帳號或密碼錯誤" +Auth.Error02="登入失敗: %1" Auth.ForgotPassword="忘記密碼?" -Auth.Help="更多登入幫助" -Auth.Hint01="請提高警覺:17LIVE 不會以任何分期付款失敗等名義要求您提供帳戶資訊、至ATM操作或提供信用卡等資料。" +Auth.Help="更多登入幫助" +Auth.Hint01="請注意:17LIVE 絕不會以分期付款失敗等理由,要求您提供帳戶資訊、操作 ATM 或提供信用卡詳細資料。" Auth.LoginSuccess="登入成功" -Auth.LoginSuccess.Tip="歡迎來到17Live,%1!" +Auth.LoginSuccess.Tip="歡迎來到 17LIVE,%1!" Auth.Password="密碼" -Auth.Password.Placeholder="********" -Auth.Password.Tip="若您已驗證電話號碼,您可點擊右側「忘記密碼」來獲得新密碼進行17帳號登入,或點選下方「更多登入幫助」,連結到說明文章。" +Auth.Password.Tip="若您已驗證電話號碼,可點擊右側「忘記密碼」取得新密碼,並使用 17 帳號登入,或點擊下方「更多登入幫助」查看說明文章。" Auth.Register="註冊新帳號" Auth.SignIn="登入" -Auth.Username="帳號" -ChatRoom.Title="留言" -CustomEvent.Cancel="取消" +ChatRoom.Title="留言視窗" CustomEvent.Close="關閉" -CustomEvent.Confirm.Close.Title="關閉自訂活動提醒" -CustomEvent.Confirm.Close.Yes="確認關閉活動" -CustomEvent.Confirm.CloseEvent="確定要關閉活動嗎?關閉後活動內容與結果將不再顯示。" +CustomEvent.Confirm.Close.Title="關閉自訂活動確認" +CustomEvent.Confirm.Close.Yes="確認關閉" +CustomEvent.Confirm.CloseEvent="確定要關閉活動嗎?關閉後將不再顯示活動內容與結果" CustomEvent.Confirm.Stop.Title="停止自訂活動提醒" -CustomEvent.Confirm.Stop.Yes="確定停止" -CustomEvent.Confirm.StopEvent="確定要停止活動嗎?停止後活動即結束並顯示結果。" +CustomEvent.Confirm.Stop.Yes="確認停止" +CustomEvent.Confirm.StopEvent="確定要停止活動嗎?活動將結束並顯示結果。" CustomEvent.Create="建立活動" CustomEvent.DailyGoal="每日目標(寶寶幣)" CustomEvent.DailyGoal.Placeholder="666666" -CustomEvent.Description="活動描述" -CustomEvent.Description.Placeholder="例:前三名可以要求主播唱生日快樂歌!" -CustomEvent.Dialog.Title="自訂活動 (選填)" -CustomEvent.EndDate="活動結束日期(至多30天)" +CustomEvent.Description="活動說明" +CustomEvent.Description.Placeholder="範例:送禮前三名可以點歌唱生日快樂歌!" +CustomEvent.Dialog.Title="建立你的專屬活動" +CustomEvent.EndDate="活動結束日期(最長30天)" CustomEvent.Error="提示" CustomEvent.Error.CloseFailed="關閉活動失敗" CustomEvent.Error.CreateFailed="建立活動失敗" -CustomEvent.Error.DescriptionTooLong="活動描述不能超過200個字符" -CustomEvent.Error.EmptyDescription="請輸入活動描述" +CustomEvent.Error.DescriptionTooLong="活動說明不能超過200字" +CustomEvent.Error.EmptyDescription="請輸入活動說明" CustomEvent.Error.EmptyTitle="活動標題不能為空" -CustomEvent.Error.GiftNotSelected="請選擇活動禮物" -CustomEvent.Error.MaxGifts="最多只能選擇 %1 個禮物" -CustomEvent.Error.NoGifts="必須選擇禮物" -CustomEvent.Error.StopFailed="中止活動失敗" -CustomEvent.Error.Title="錯誤" -CustomEvent.Error.TitleEmpty="請輸入活動標題" +CustomEvent.Error.MaxGifts="最多只能選擇%1個禮物" +CustomEvent.Error.NoGifts="必須至少選擇一個禮物" +CustomEvent.Error.StopFailed="停止活動失敗" CustomEvent.Gifts="活動禮物" -CustomEvent.LoadingGifts="正在載入禮物..." +CustomEvent.LoadingGifts="正在讀取禮物..." CustomEvent.SelectedGifts.Placeholder="選擇禮物" CustomEvent.Stop="停止" -CustomEvent.Success="成功" -CustomEvent.Success.Created="自訂活動創建成功!" CustomEvent.Title="活動標題" -CustomEvent.Title.Placeholder="例:一起來慶祝我的生日吧!" -CustomEvent.TotalGoal="總體目標(寶寶幣)" +CustomEvent.Title.Placeholder="範例:一起來慶生吧!" +CustomEvent.TotalGoal="總目標(寶寶幣)" CustomEvent.TotalGoal.Placeholder="88888888" -Error.Confirm="確定" -Live.ChangeEvent.Failed="切換活動需間隔5分鐘。" +Diagnostics.Browse="瀏覽…" +Diagnostics.Cancel="取消" +Diagnostics.Categories.ConfigSnapshot="設定快照" +Diagnostics.Categories.CrashInfo="崩潰資訊" +Diagnostics.Categories.NetworkLogs="網路記錄" +Diagnostics.Categories.NetworkRequests="網路請求" +Diagnostics.Categories.OBSLogs="OBS 記錄" +Diagnostics.Categories.PluginLogs="插件記錄" +Diagnostics.Categories.PrivacyFilter="隱私過濾" +Diagnostics.Categories.SystemInfo="系統資訊" +Diagnostics.Categories.Title="類別" +Diagnostics.Collect="收集" +Diagnostics.Description="此工具收集診斷資訊以協助排除問題。所有收集的資料僅儲存在本地,不會自動上傳。" +Diagnostics.OutputPath="輸出路徑:" +Diagnostics.Privacy.WarningMessage="警告:部分診斷資料可能包含個人資訊。分享前請仔細檢查內容。" +Diagnostics.Privacy.WarningTitle="隱私警告" +Diagnostics.SavePackage="儲存套件" +Diagnostics.Status.Collecting="正在收集…" +Diagnostics.Status.CollectorFailed="建立診斷收集器失敗" +Diagnostics.Status.Error="錯誤" +Diagnostics.Status.ErrorMessage="建立診斷套件失敗" +Diagnostics.Status.ErrorTitle="診斷收集失敗" +Diagnostics.Status.Failed="失敗" +Diagnostics.Status.FilesCollected="已收集檔案" +Diagnostics.Status.OpenFolderQuestion="是否要開啟包含這些檔案的資料夾?" +Diagnostics.Status.OpenFolderTitle="開啟資料夾" +Diagnostics.Status.Output="輸出" +Diagnostics.Status.Ready="就緒" +Diagnostics.Status.Success="成功" +Diagnostics.Title="診斷收集器" +Live.ChangeEvent.Failed="更換活動需要間隔5分鐘。" Live.Common.Notice="提示" -Live.Common.StreamingInProgress="正在直播中,暫時不能操作" -Live.Create.Feature207Disabled="此區域暫不支援開播" -Live.Create.GetSelfInfoFailed="獲得開播資訊失敗,請登出然後重新登入後再試" +Live.Common.StreamingInProgress="直播中,暫時無法操作" +Live.Create.Feature207Disabled="此地區暫不支援開播" +Live.Create.GetSelfInfoFailed="獲取開播資訊失敗,請重啟OBS" Live.Create.Title="不支援開播" Live.EventChange.Confirm.Cancel="取消" Live.EventChange.Confirm.Confirm="確認" -Live.EventChange.Confirm.Message="將活動切換為 %1?\n\n5秒後將會切換至新活動。" +Live.EventChange.Confirm.Message="是否切換活動為 %1?\n\n5秒後將切換為新活動。" Live.EventChange.Confirm.Title="切換活動" -Live.EventChange.CoolDown="活動切換冷卻時間:%1:%2" -Live.Settings="設定" +Live.EventChange.CoolDown="更換活動冷卻中:%1:%2" +Live.Settings="開播設定" Live.Settings.AddTag="新增" -Live.Settings.Archive.AutoPublish="自動發布預覽" -Live.Settings.Archive.AutoPublish.Tip="自動以影片的方式發佈在個人頁。您可以前往17LIVE App編輯該部典藏的相關內容(如標題、標籤等)" -Live.Settings.Archive.ClipPermission="允許直播剪輯身份" -Live.Settings.Archive.ClipPermission.Tip="有權限的觀眾可以在你直播時剪輯直播片段,並分享出去。" -Live.Settings.Archive.Record="典藏直播" -Live.Settings.Archive.Record.Tip="儲存直播內容7天,並且只有您本人可以觀看。\n(限制:不超過8小時,PK/群聊內容皆不支持。)" -Live.Settings.ArmyOnly="戰隊限定觀看" -Live.Settings.ArmyOnly.Tip="您的戰隊等級需要高於1,才能使用戰隊限定開播喔!加油~" +Live.Settings.Archive.AutoPublish="自動發布直播預覽" +Live.Settings.Archive.AutoPublish.Tip="直播結束後自動發布到個人頁面。 您可以前往 17LIVE App 管理您的直播存檔資訊,例如標題和標籤。" +Live.Settings.Archive.ClipPermission="允許剪輯精華" +Live.Settings.Archive.ClipPermission.Tip="允許特定觀眾剪輯您的直播片段並分享。" +Live.Settings.Archive.Record="留存直播回放" +Live.Settings.Archive.Record.Tip="直播存檔將保留 7 天,僅供您本人觀看。\n(限制:最長 8 小時;不支援 PK/派對直播。)" +Live.Settings.ArmyOnly="軍隊限定觀看" +Live.Settings.ArmyOnly.Tip="你的軍隊等級必須大於 1 才能使用軍隊限定直播功能。繼續加油吧!" Live.Settings.BroadcastMode="直播模式" -Live.Settings.Category="類別" -Live.Settings.CloseLive="關閉直播" +Live.Settings.Category="分類" +Live.Settings.CloseLive="關播" Live.Settings.CloseLive.Auto.Cancel="取消" -Live.Settings.CloseLive.Auto.Confirm="確認關閉" -Live.Settings.CloseLive.Auto.Message="直播狀態異常檢測,已連續%1次檢測失敗。\n\n可能原因:\n• 網絡連線不穩定\n• 伺服器回應異常\n• 本地網絡環境變化\n\n要結束直播嗎?" -Live.Settings.CloseLive.Auto.Title="直播自動關閉確認" -Live.Settings.CloseLive.Confirm="確定要關播嗎?" -Live.Settings.CloseLive.Confirm.Button="確定關播" +Live.Settings.CloseLive.Auto.Confirm="確認關播" +Live.Settings.CloseLive.Auto.Message="偵測到直播狀態異常,已連續 %1 次檢查失敗。\n\n可能原因:\n• 網路連線不穩定\n• 伺服器回應異常\n• 本地網路環境變動\n\n是否要結束直播?" +Live.Settings.CloseLive.Auto.Title="自動關播確認" +Live.Settings.CloseLive.Confirm="確定要結束直播嗎?" +Live.Settings.CloseLive.Confirm.Button="結束直播" Live.Settings.Error="錯誤" Live.Settings.Event="活動" -Live.Settings.Event.Tip="每隔5分鐘可以切換一次活動" -Live.Settings.GetRoomInfoError="獲取 Room Info 錯誤" -Live.Settings.GetRtmpError="獲取 RTMP 設定錯誤" +Live.Settings.Event.Tip="每5分鐘可更換一次活動" Live.Settings.GroupCall="派對直播" -Live.Settings.GroupCall.Help.Button="了解" -Live.Settings.GroupCall.Help.Content="

如何開啟派對直播

1. 在 OBS 開啟「派對直播(Group Call)」

啟用後即可加入或接收派對直播邀請。

2. 直播後於17LIVE App內進行派對直播

在OBS開啟直播後,利用手機開啟17LIVE App開啟直播間進入『主播遙控器』,點擊右上方派對按鈕即可開啟派對直播。詳情可至常見問題查看更多。

3. 避免回音問題

為了避免主播控制器的聲音被電腦麥克風收錄,造成回音問題,請使用耳機收聽派對內容,或讓保持主播控制器與電腦麥克風距離。也可在對方講話時,在OBS內靜音電腦麥克風。

" -Live.Settings.GroupCall.Help.Title="如何開啟派對直播" +Live.Settings.GroupCall.Help.Button="知道了" +Live.Settings.GroupCall.Help.Content="

如何啟用派對直播

1. 在 OBS 開啟「派對直播」

啟用後,您可以加入或接收派對直播的邀請。

2. 在 17LIVE App 設定派對直播

在 OBS 開始直播後,進入 17LIVE App 的「遙控器」,點擊右上角的按鈕開始派對直播。詳細資訊請參考 常見問題

3. 避免回音問題

為避免回音,請使用耳機收聽遙控器上的派對直播語音,或將遙控器遠離電腦麥克風。您也可以在對方說話時,在 OBS 中將麥克風靜音。

" +Live.Settings.GroupCall.Help.Title="如何開始派對直播?" Live.Settings.GroupCall.Help.Tooltip="點擊查看派對直播說明" -Live.Settings.GroupCall.Tip="開啟此設定才有機會加入或是接收派對直播的邀請。" -Live.Settings.Layout="開播格式" -Live.Settings.Layout.Landscape="直式播出" -Live.Settings.Layout.Portrait="橫式播出" -Live.Settings.LiveCreated="17Live 直播已建立" -Live.Settings.LiveCreated.Tip="17Live 直播已建立,點擊「開始直播」按鈕僅開始直播;點擊「關閉直播」按鈕可關閉直播。" -Live.Settings.LiveNotification="開播通知" -Live.Settings.LoadError="加載直播間資訊失敗" -Live.Settings.Loading="正在加載直播間資料,請稍候..." +Live.Settings.GroupCall.Tip="開啟此設定以加入或接收派對直播邀請。若要使用多平台轉播,請關閉此設定。" +Live.Settings.Layout="直播間配置" +Live.Settings.Layout.Landscape="橫式直播" +Live.Settings.Layout.Portrait="直式直播" +Live.Settings.LiveCreated="17LIVE 直播場次已建立" +Live.Settings.LiveCreated.Tip="17LIVE 直播場次已建立。\n點擊「開始直播並串流」將啟動場次並開始 OBS 推流;\n點擊「僅開始直播」將啟動場次但暫不推流;\n點擊「關閉直播」將結束場次。" +Live.Settings.LiveNotification="直播通知" +Live.Settings.Loading="正在讀取直播間資料,請稍候..." Live.Settings.No="否" -Live.Settings.Rank0.army_only_stream_level_setting_all_level="全部階級,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_level="{name} 及以上,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_only_stream_level_setting_top_level="僅 {name},符合條件成員數:{subscribersAmount}" -Live.Settings.Rank0.army_rank_name_1="士官長" -Live.Settings.Rank0.army_rank_name_2="上尉" -Live.Settings.Rank0.army_rank_name_3="上校" -Live.Settings.Rank0.army_rank_name_4="上將" -Live.Settings.Rank0.army_rank_name_5="下士" -Live.Settings.Rank1.army_only_stream_level_setting_all_level="全部階級,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_level="等級 {value} 及以上,符合條件成員數:{subscribersAmount}" -Live.Settings.Rank1.army_only_stream_level_setting_top_level="僅等級 {value},符合條件成員數:{subscribersAmount}" +Live.Settings.RequiredDataMissing="缺少必要資料。請檢查您的網路連線並重試。" +Live.Settings.Retry="重試" Live.Settings.Save="儲存設定" -Live.Settings.Save.Category.Empty="請選擇直播類別" -Live.Settings.Save.Success="設定已成功儲存" +Live.Settings.Save.Success="設定已儲存" Live.Settings.Save.Title="儲存設定" Live.Settings.Save.Title.Empty="請輸入直播標題" -Live.Settings.ShowInHotPage="顯示在熱門頁" +Live.Settings.ShowInHotPage="顯示在熱門頁面" Live.Settings.StartLive="開始直播" -Live.Settings.StartLiveAndStream="開始直播並串流" -Live.Settings.StartLiveOnly="開始直播" +Live.Settings.StartLiveOnly="僅開始直播" Live.Settings.StartStreaming="開始串流" -Live.Settings.StartStreaming.Tip="是否同時開始串流" -Live.Settings.StopLive="停止直播" +Live.Settings.StartStreaming.Tip="是否同時開始串流?" +Live.Settings.StopLive="結束直播" Live.Settings.Tags="標籤" -Live.Settings.Tags.LengthError="標籤最大為 24 個字元" -Live.Settings.Tags.Placeholder="按下Enter鍵隔開標籤" +Live.Settings.Tags.LengthError="標籤最多24個字元" +Live.Settings.Tags.Placeholder="按 Enter 分隔標籤" Live.Settings.Title="標題 (必填)" -Live.Settings.Title.Placeholder="給直播間一個標題吧!" +Live.Settings.Title.Placeholder="給直播取個標題吧!" Live.Settings.UserCondition="用戶條件" -Live.Settings.VirtualLiver="是,我是虛擬主播。" -Live.Settings.VirtualLiver.Tip="若你是虛擬主播請勾選,用戶就能更輕易地找到你的直播。" -Live.Settings.VirtualLiver.Title="你是虛擬主播嗎?" +Live.Settings.VirtualLiver="是的,我是虛擬主播。" +Live.Settings.VirtualLiver.Tip="如果您是虛擬主播,請勾選此框,以便用戶更容易找到您的直播。" +Live.Settings.VirtualLiver.Title="您是虛擬主播嗎?" +Live.Settings.Warning="警告" Live.Settings.Yes="是" -Live.StreamList="直播列表" -Live.StreamList.Empty="當前沒有直播信息記錄,到'開播設定'中設置" +Live.StreamList="直播場次列表" +Live.StreamList.Empty="沒有直播場次記錄。請前往「開播設定」進行設定。" Logout.Warning.Button.No="否" Logout.Warning.Button.Yes="是" -Logout.Warning.Message="登出會中斷直播,確定嗎?" +Logout.Warning.Message="登出將會中斷直播,確定要登出嗎?" Logout.Warning.Title="提示" -Menu.Broadcast="開始直播" +Menu.Broadcast="開播" Menu.ChatRoom="留言" Menu.CheckUpdate="檢查更新" Menu.CheckUpdate.Url="https://github.com/17media/obs-plugin/releases/latest" -Menu.Dock="17LIVE 功能塢" +Menu.Diagnostics="一鍵支援" +Menu.Dock="17LIVE 控制面板" Menu.Help="幫助" -Menu.Help.Url="https://17mediahelp.zendesk.com/hc/en-us/articles/31066637227161-OBS%E7%9B%B4%E6%92%AD" -Menu.LiveList="直播列表" -Menu.RockZone="摇滚区" -Menu.Settings="設定" +Menu.Help.Url="https://17mediahelp.zendesk.com/hc/en-us/articles/31079492940313-Stream-with-OBS" +Menu.LiveList="場次列表" +Menu.PreviewDock="預覽視窗" +Menu.RockZone="搖滾區" Menu.SignIn="登入" Menu.SignOut="登出" +MultiRTMP.AddStream="新增推流" +MultiRTMP.AddStream.Title="新增推流平台" +MultiRTMP.AuthorizationFailed.Text="授權失敗:%1\n\n請檢查您的憑證並重試。" +MultiRTMP.AuthorizationFailed.Title="授權失敗" +MultiRTMP.Config.Encoder.Audio="音訊編碼器" +MultiRTMP.Config.Encoder.Video="視訊編碼器" +MultiRTMP.Config.Tab.Audio="音訊" +MultiRTMP.Config.Tab.Output="輸出" +MultiRTMP.Config.Tab.Video="視訊" +MultiRTMP.Config.Title="多平台推流設定" +MultiRTMP.Delete="刪除" +MultiRTMP.Delete.Confirm="確定要刪除此平台嗎?" +MultiRTMP.Delete.Title="刪除平台" +MultiRTMP.Dock.StartAll="全部開始" +MultiRTMP.Dock.StartAll.WithCount="全部開始 (%1)" +MultiRTMP.Dock.StopAll="全部停止" +MultiRTMP.Dock.StopAll.WithCount="全部停止 (%1)" +MultiRTMP.Dock.Title="多平台推流" +MultiRTMP.Edit="編輯" +MultiRTMP.EditStream.Title="編輯平台" +MultiRTMP.Error.AddFailed="新增平台失敗" +MultiRTMP.Error.Title="多平台推流錯誤" +MultiRTMP.Error.UpdateFailed="更新平台失敗" +MultiRTMP.List.EmptyTip="請建立一個新的推流" +MultiRTMP.Precheck.GroupCallNotSupported="派對直播(Group Call)不支援多平台推流。請結束目前直播後重新開始。" +MultiRTMP.Precheck.LiveNotStarted="請先開始 17LIVE 直播,再啟動多平台推流。" +MultiRTMP.Precheck.StartObsStreamingFirst="17LIVE 直播已開始但 OBS 尚未推流。請先開始推流。" +MultiRTMP.Precheck.StreamManagerUnavailable="17LIVE 串流服務無法使用。請重啟 OBS 再試一次。" +MultiRTMP.Start="開始" +MultiRTMP.Stats.Duration="連線時間" +MultiRTMP.Stats.FrameRate="幀率" +MultiRTMP.Stats.UploadRate="上傳速率" +MultiRTMP.Status.Connected="已連線" +MultiRTMP.Status.Connecting="連線中" +MultiRTMP.Status.Disconnected="已斷線" +MultiRTMP.Stop="停止" +MultiRTMP.StopAllConfirm="確定要停止所有平台的推流嗎?" +MultiRTMP.Wait="請稍候…" +MultiRtmp.Config.AdvancedSettings="進階設定" +MultiRtmp.Config.Audio.UseOBS="使用 OBS 音訊設定" +MultiRtmp.Config.Authorize="授權登入" +MultiRtmp.Config.Cancel="取消" +MultiRtmp.Config.Confirm="確定" +MultiRtmp.Config.Deauthorize="解除授權" +MultiRtmp.Config.Protocol="協定" +MultiRtmp.Config.StreamName="串流名稱" +MultiRtmp.Config.Video.FPSDenominator="FPS 分母" +MultiRtmp.Config.Video.OutputScene="輸出場景" +MultiRtmp.Config.Video.Resolution="解析度" +MultiRtmp.Config.Video.UseOBS="使用 OBS 視訊設定" +PreviewDock.Tip.AnimationOnly="此視窗僅供預覽禮物動畫,不會播出。橫式直播時聽眾端不會顯示禮物動畫。" +PreviewDock.Title="預覽視窗" RockZone.Badge.Army.Template="階級 %1 (%2)" RockZone.Badge.Guardian="守護騎士" RockZone.Badge.TopContributor="本次送禮最多" -RockZone.EmptyList="目前尚無送禮觀眾" -RockZone.Followers="粉絲數" -RockZone.Following="追蹤中" -RockZone.Hint="僅顯示前 50 名用戶在名單上。" -RockZone.Likes="愛心數" -RockZone.Loading="正在載入用戶⋯" -RockZone.PokeAll="戳所有朋友" -RockZone.PokeAll.Success="已成功戳了所有用戶。" -RockZone.PokeUser="戳戳TA" -RockZone.PokeUser.Success="已成功戳了該用戶。" +RockZone.EmptyList="目前沒有觀眾送禮。" +RockZone.Followers="粉絲" +RockZone.Following="關注中" +RockZone.Hint="列表中僅顯示前 50 名用戶。" +RockZone.Likes="按讚" +RockZone.PokeAll="戳全部好友" +RockZone.PokeUser="戳他" RockZone.Title="搖滾區" -RockZone.UserCard.followers="粉絲數" -RockZone.UserCard.following="追蹤中" -RockZone.UserCard.likes="愛心數" Update.Cancel="取消" -Update.CheckFailed="檢查更新失敗" -Update.CheckFailed.Message="無法檢查更新:%1" Update.DownloadComplete="下載完成" -Update.DownloadComplete.Message="更新檔已下載到:%1\n\n請手動安裝下載的文件。" +Update.DownloadComplete.Message="更新檔已下載至:%1\n\n請手動安裝下載的檔案。" Update.DownloadFailed="下載失敗" Update.DownloadFailed.NetworkError="下載失敗:%1" -Update.DownloadFailed.NoPackage="未找到適合當前系統的安裝檔。" -Update.DownloadFailed.SaveError="無法保存下載的文件。" -Update.DownloadProgress="正在下載更新檔⋯%1/%2 MB" -Update.Downloading="正在下載更新檔⋯" -Update.FileExists="文件已存在" -Update.FileExists.Message="文件 %1 已存在。\n\n是否要重新下載?" +Update.DownloadFailed.NoPackage="找不到適合您系統的安裝程式。" +Update.DownloadProgress="正在下載更新檔... %1/%2 MB" +Update.Downloading="正在下載更新檔..." +Update.FileExists="檔案已存在" +Update.FileExists.Message="檔案 %1 已存在。\n\n要重新下載嗎?" Update.InstallReminder="安裝提醒" -Update.InstallReminder.Message="請手動安裝下載文件夾中的 %1" +Update.InstallReminder.Message="請手動安裝下載資料夾中的 %1" Update.NewVersionFound="發現新版本" -Update.NewVersionFound.Message="17LIVE OBS擴充套件有新版本 %1 ,\n是否下載並安裝更新?" -Update.NewVersionFound.No="否" -Update.NewVersionFound.Yes="是" +Update.NewVersionFound.Message="發現 17LIVE OBS 插件的新版本 (%1)。\n\n要下載並安裝更新嗎?" + +MultiRTMP.ErrTitle.MissingServerKey="參數缺失" +MultiRTMP.ErrDesc.MissingServerKey="伺服器 URL 或金鑰為空" +MultiRTMP.ErrSolution.MissingServerKey="請檢查設定或重新獲取串流資訊" + +MultiRTMP.ErrTitle.UnknownPlatform="未知平台" +MultiRTMP.ErrDesc.UnknownPlatform="無法識別的串流平台" +MultiRTMP.ErrSolution.UnknownPlatform="請更換平台或檢查平台設定" + +MultiRTMP.ErrTitle.AuthInvalid="驗證無效" +MultiRTMP.ErrDesc.AuthInvalid="登入或令牌無效" +MultiRTMP.ErrSolution.AuthInvalid="請重新登入或重新整理令牌" + +MultiRTMP.ErrTitle.StartFailed="啟動失敗" +MultiRTMP.ErrDesc.StartFailed="OBS 輸出啟動錯誤" +MultiRTMP.ErrSolution.StartFailed="請檢查編碼器設定和串流參數" + +MultiRTMP.ErrTitle.ConnectFailed="連線失敗" +MultiRTMP.ErrDesc.ConnectFailed="網路連線或握手失敗" +MultiRTMP.ErrSolution.ConnectFailed="請檢查網路和伺服器 URL" + +MultiRTMP.ErrTitle.APIError="API 錯誤" +MultiRTMP.ErrDesc.APIError="平台 API 返回錯誤" +MultiRTMP.ErrSolution.APIError="請稍後重試或聯繫支援" + +MultiRTMP.ErrTitle.Generic="啟動失敗" +MultiRTMP.ErrDesc.Generic="發生未知錯誤" +MultiRTMP.ErrSolution.Generic="請檢查網路和設定;如需協助請聯繫支援" +MultiRtmp.Config.Reauthorize="已授權 — 點擊重新授權" +MultiRtmp.Config.Deauthorize="解除授權" +MultiRTMP.ErrTitle.NetworkError="網路錯誤" +MultiRTMP.ErrDesc.NetworkError="網路連線或握手失敗" +MultiRTMP.ErrSolution.NetworkError="檢查網路/代理/防火牆/DNS及時間;重試或切換網路" + +MultiRTMP.CloseLive.InfoTip="此操作不會關播其他平台" +MultiRTMP.Stop.InfoTip17LIVE="此操作不會關閉17LIVE平台的直播" + +Live.Settings.AutoRes.Title="自動設定解析度" +Live.Settings.AutoRes.Msg.Landscape="已選擇橫向模式。OBS 基礎解析度和輸出解析度將設定為 1280x720。\n\n您可以在直播開始前,於 OBS 設定中手動調整並覆蓋這些設定。" +Live.Settings.AutoRes.Msg.Portrait="已選擇直向模式。OBS 基礎解析度和輸出解析度將設定為 720x1280。\n\n您可以在直播開始前,於 OBS 設定中手動調整並覆蓋這些設定。" +PreviewDock.LoadingGifts="動畫載入中..." +PreviewDock.Initializing="正在初始化視訊顯示..." +ChatRoom.LoadingGifts="17LIVE禮物數據加載中..." +Live.Create.Failed="創建直播失敗" + +Api.Error.39="請先完成電話認證" +Api.Error.35="目前無法開播,請先確認您的帳號資訊及大頭貼。" +Api.Error.Generic="目前無法開播,請聯繫客服尋求協助。(%1)" diff --git a/data/multi-rtmp-errors.json b/data/multi-rtmp-errors.json new file mode 100644 index 0000000..4d0b595 --- /dev/null +++ b/data/multi-rtmp-errors.json @@ -0,0 +1,42 @@ +{ + "MissingServerKey": { + "title_key": "MultiRTMP.ErrTitle.MissingServerKey", + "desc_key": "MultiRTMP.ErrDesc.MissingServerKey", + "solution_key": "MultiRTMP.ErrSolution.MissingServerKey" + }, + "UnknownPlatform": { + "title_key": "MultiRTMP.ErrTitle.UnknownPlatform", + "desc_key": "MultiRTMP.ErrDesc.UnknownPlatform", + "solution_key": "MultiRTMP.ErrSolution.UnknownPlatform" + }, + "AuthInvalid": { + "title_key": "MultiRTMP.ErrTitle.AuthInvalid", + "desc_key": "MultiRTMP.ErrDesc.AuthInvalid", + "solution_key": "MultiRTMP.ErrSolution.AuthInvalid" + }, + "StartFailed": { + "title_key": "MultiRTMP.ErrTitle.StartFailed", + "desc_key": "MultiRTMP.ErrDesc.StartFailed", + "solution_key": "MultiRTMP.ErrSolution.StartFailed" + }, + "ConnectFailed": { + "title_key": "MultiRTMP.ErrTitle.ConnectFailed", + "desc_key": "MultiRTMP.ErrDesc.ConnectFailed", + "solution_key": "MultiRTMP.ErrSolution.ConnectFailed" + }, + "APIError": { + "title_key": "MultiRTMP.ErrTitle.APIError", + "desc_key": "MultiRTMP.ErrDesc.APIError", + "solution_key": "MultiRTMP.ErrSolution.APIError" + }, + "NetworkError": { + "title_key": "MultiRTMP.ErrTitle.NetworkError", + "desc_key": "MultiRTMP.ErrDesc.NetworkError", + "solution_key": "MultiRTMP.ErrSolution.NetworkError" + }, + "Generic": { + "title_key": "MultiRTMP.ErrTitle.Generic", + "desc_key": "MultiRTMP.ErrDesc.Generic", + "solution_key": "MultiRTMP.ErrSolution.Generic" + } +} \ No newline at end of file diff --git a/data/preview_config.json b/data/preview_config.json new file mode 100644 index 0000000..bc7483f --- /dev/null +++ b/data/preview_config.json @@ -0,0 +1,28 @@ +{ + "version": "1.0", + "description": "Configuration for 17Live Preview Dock Widget", + "browser_source": { + "enabled": true, + "url": "https://17.live", + "width": 640, + "height": 480, + "css": "body { background: transparent; }", + "shutdown": false, + "restart_when_active": false, + "fps": 30, + "reroute_audio": false + }, + "preview_settings": { + "show_browser_overlay": true, + "overlay_opacity": 0.8, + "auto_refresh_interval": 0, + "enable_interaction": false + }, + "ui_settings": { + "dock_title": "Preview Window", + "default_width": 640, + "default_height": 480, + "resizable": true, + "floating": false + } +} \ No newline at end of file diff --git a/deps/wrappers/CMakeLists.txt b/deps/wrappers/CMakeLists.txt deleted file mode 100644 index 1f68b41..0000000 --- a/deps/wrappers/CMakeLists.txt +++ /dev/null @@ -1,49 +0,0 @@ -cmake_minimum_required(VERSION 3.28...3.30) - -if (APPLE) - set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64") -endif() - -find_package(Qt6 REQUIRED Core Widgets) - -add_library(wrappers STATIC) - -# 设置源文件 -target_sources(wrappers - PRIVATE - qt-wrappers.cpp -) - -# 设置头文件 -target_sources(wrappers - PUBLIC - qt-wrappers.hpp -) - -# 设置包含目录 -target_include_directories(wrappers - PUBLIC - "${CMAKE_CURRENT_SOURCE_DIR}" -) - -# 设置链接库 -target_link_libraries(wrappers - PUBLIC - Qt::Core - Qt::Widgets - OBS::libobs -) - -# 设置编译选项 -target_compile_features(wrappers PUBLIC cxx_std_17) - -set_target_properties(wrappers PROPERTIES - AUTOMOC ON - AUTORCC ON - AUTOUIC ON -) - -# 如果需要,设置导出宏 -if(BUILD_SHARED_LIBS) - target_compile_definitions(wrappers PRIVATE WRAPPERS_EXPORTS) -endif() diff --git a/deps/wrappers/qt-wrappers.cpp b/deps/wrappers/qt-wrappers.cpp deleted file mode 100644 index 20a5a0c..0000000 --- a/deps/wrappers/qt-wrappers.cpp +++ /dev/null @@ -1,352 +0,0 @@ -/****************************************************************************** - Copyright (C) 2023 by Lain Bailey - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -******************************************************************************/ - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "moc_qt-wrappers.cpp" - -static inline void OBSErrorBoxva(QWidget *parent, const char *msg, va_list args) { - char full_message[8192]; - vsnprintf(full_message, sizeof(full_message), msg, args); - - QMessageBox::critical(parent, "Error", full_message); -} - -void OBSErrorBox(QWidget *parent, const char *msg, ...) { - va_list args; - va_start(args, msg); - OBSErrorBoxva(parent, msg, args); - va_end(args); -} - -QMessageBox::StandardButton OBSMessageBox::question(QWidget *parent, const QString &title, - const QString &text, - QMessageBox::StandardButtons buttons, - QMessageBox::StandardButton defaultButton) { - QMessageBox mb(QMessageBox::Question, title, text, QMessageBox::NoButton, parent); - mb.setDefaultButton(defaultButton); - - if (buttons & QMessageBox::Ok) { - QPushButton *button = mb.addButton(QMessageBox::Ok); - button->setText(tr("OK")); - } -#define add_button(x) \ - if (buttons & QMessageBox::x) { \ - QPushButton *button = mb.addButton(QMessageBox::x); \ - button->setText(tr(#x)); \ - } - add_button(Open); - add_button(Save); - add_button(Cancel); - add_button(Close); - add_button(Discard); - add_button(Apply); - add_button(Reset); - add_button(Yes); - add_button(No); - add_button(Abort); - add_button(Retry); - add_button(Ignore); -#undef add_button - return (QMessageBox::StandardButton) mb.exec(); -} - -void OBSMessageBox::information(QWidget *parent, const QString &title, const QString &text) { - QMessageBox mb(QMessageBox::Information, title, text, QMessageBox::NoButton, parent); - mb.addButton(tr("OK"), QMessageBox::AcceptRole); - mb.exec(); -} - -void OBSMessageBox::warning(QWidget *parent, const QString &title, const QString &text, - bool enableRichText) { - QMessageBox mb(QMessageBox::Warning, title, text, QMessageBox::NoButton, parent); - if (enableRichText) - mb.setTextFormat(Qt::RichText); - mb.addButton(tr("OK"), QMessageBox::AcceptRole); - mb.exec(); -} - -void OBSMessageBox::critical(QWidget *parent, const QString &title, const QString &text) { - QMessageBox mb(QMessageBox::Critical, title, text, QMessageBox::NoButton, parent); - mb.addButton(tr("OK"), QMessageBox::AcceptRole); - mb.exec(); -} - -uint32_t TranslateQtKeyboardEventModifiers(Qt::KeyboardModifiers mods) { - int obsModifiers = INTERACT_NONE; - - if (mods.testFlag(Qt::ShiftModifier)) - obsModifiers |= INTERACT_SHIFT_KEY; - if (mods.testFlag(Qt::AltModifier)) - obsModifiers |= INTERACT_ALT_KEY; -#ifdef __APPLE__ - // Mac: Meta = Control, Control = Command - if (mods.testFlag(Qt::ControlModifier)) - obsModifiers |= INTERACT_COMMAND_KEY; - if (mods.testFlag(Qt::MetaModifier)) - obsModifiers |= INTERACT_CONTROL_KEY; -#else - // Handle windows key? Can a browser even trap that key? - if (mods.testFlag(Qt::ControlModifier)) - obsModifiers |= INTERACT_CONTROL_KEY; - if (mods.testFlag(Qt::MetaModifier)) - obsModifiers |= INTERACT_COMMAND_KEY; - -#endif - - return obsModifiers; -} - -QDataStream &operator<<(QDataStream &out, const std::vector> &) { - return out; -} - -QDataStream &operator>>(QDataStream &in, std::vector> &) { - return in; -} - -QDataStream &operator<<(QDataStream &out, const OBSScene &scene) { - return out << QString(obs_source_get_uuid(obs_scene_get_source(scene))); -} - -QDataStream &operator>>(QDataStream &in, OBSScene &scene) { - QString uuid; - - in >> uuid; - - OBSSourceAutoRelease source = obs_get_source_by_uuid(QT_TO_UTF8(uuid)); - scene = obs_scene_from_source(source); - - return in; -} - -QDataStream &operator<<(QDataStream &out, const OBSSource &source) { - return out << QString(obs_source_get_uuid(source)); -} - -QDataStream &operator>>(QDataStream &in, OBSSource &source) { - QString uuid; - - in >> uuid; - - OBSSourceAutoRelease source_ = obs_get_source_by_uuid(QT_TO_UTF8(uuid)); - source = source_; - - return in; -} - -void DeleteLayout(QLayout *layout) { - if (!layout) - return; - - for (;;) { - QLayoutItem *item = layout->takeAt(0); - if (!item) - break; - - QLayout *subLayout = item->layout(); - if (subLayout) { - DeleteLayout(subLayout); - } else { - delete item->widget(); - delete item; - } - } - - delete layout; -} - -class QuickThread : public QThread { - public: - explicit inline QuickThread(std::function func_) : func(func_) {} - - private: - virtual void run() override { - func(); - } - - std::function func; -}; - -QThread *CreateQThread(std::function func) { - return new QuickThread(func); -} - -volatile long insideEventLoop = 0; - -void ExecuteFuncSafeBlock(std::function func) { - QEventLoop eventLoop; - - auto wait = [&]() { - func(); - QMetaObject::invokeMethod(&eventLoop, "quit", Qt::QueuedConnection); - }; - - os_atomic_inc_long(&insideEventLoop); - QScopedPointer thread(CreateQThread(wait)); - thread->start(); - eventLoop.exec(); - thread->wait(); - os_atomic_dec_long(&insideEventLoop); -} - -void ExecuteFuncSafeBlockMsgBox(std::function func, const QString &title, - const QString &text) { - QMessageBox dlg; - dlg.setWindowFlags(dlg.windowFlags() & ~Qt::WindowCloseButtonHint); - dlg.setWindowTitle(title); - dlg.setText(text); - dlg.setStandardButtons(QMessageBox::StandardButtons()); - - auto wait = [&]() { - func(); - QMetaObject::invokeMethod(&dlg, "accept", Qt::QueuedConnection); - }; - - os_atomic_inc_long(&insideEventLoop); - QScopedPointer thread(CreateQThread(wait)); - thread->start(); - dlg.exec(); - thread->wait(); - os_atomic_dec_long(&insideEventLoop); -} - -static bool enable_message_boxes = false; - -void EnableThreadedMessageBoxes(bool enable) { - enable_message_boxes = enable; -} - -void ExecThreadedWithoutBlocking(std::function func, const QString &title, - const QString &text) { - if (!enable_message_boxes) - ExecuteFuncSafeBlock(func); - else - ExecuteFuncSafeBlockMsgBox(func, title, text); -} - -bool LineEditCanceled(QEvent *event) { - if (event->type() == QEvent::KeyPress) { - QKeyEvent *keyEvent = reinterpret_cast(event); - return keyEvent->key() == Qt::Key_Escape; - } - - return false; -} - -bool LineEditChanged(QEvent *event) { - if (event->type() == QEvent::KeyPress) { - QKeyEvent *keyEvent = reinterpret_cast(event); - - switch (keyEvent->key()) { - case Qt::Key_Tab: - case Qt::Key_Backtab: - case Qt::Key_Enter: - case Qt::Key_Return: - return true; - } - } else if (event->type() == QEvent::FocusOut) { - return true; - } - - return false; -} - -void SetComboItemEnabled(QComboBox *c, int idx, bool enabled) { - QStandardItemModel *model = dynamic_cast(c->model()); - QStandardItem *item = model->item(idx); - item->setFlags(enabled ? Qt::ItemIsSelectable | Qt::ItemIsEnabled : Qt::NoItemFlags); -} - -void setClasses(QWidget *widget, const QString &newClasses) { - if (widget->property("class").toString() != newClasses) { - widget->setProperty("class", newClasses); - - /* force style sheet recalculation */ - QString qss = widget->styleSheet(); - widget->setStyleSheet("/* */"); - widget->setStyleSheet(qss); - } -} - -QString SelectDirectory(QWidget *parent, QString title, QString path) { - QString dir = QFileDialog::getExistingDirectory( - parent, title, path, QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - - return dir; -} - -QString SaveFile(QWidget *parent, QString title, QString path, QString extensions) { - QString file = QFileDialog::getSaveFileName(parent, title, path, extensions); - - return file; -} - -QString OpenFile(QWidget *parent, QString title, QString path, QString extensions) { - QString file = QFileDialog::getOpenFileName(parent, title, path, extensions); - - return file; -} - -QStringList OpenFiles(QWidget *parent, QString title, QString path, QString extensions) { - QStringList files = QFileDialog::getOpenFileNames(parent, title, path, extensions); - - return files; -} - -static void SetLabelText(QLabel *label, const QString &newText) { - if (label->text() != newText) - label->setText(newText); -} - -void TruncateLabel(QLabel *label, QString newText, int length) { - if (newText.length() < length) { - label->setToolTip(QString()); - SetLabelText(label, newText); - return; - } - - label->setToolTip(newText); - newText.truncate(length); - newText += "..."; - - SetLabelText(label, newText); -} - -void RefreshToolBarStyling(QToolBar *toolBar) { - for (QAction *action : toolBar->actions()) { - QWidget *widget = toolBar->widgetForAction(action); - - if (!widget) - continue; - - widget->style()->unpolish(widget); - widget->style()->polish(widget); - } -} diff --git a/deps/wrappers/qt-wrappers.hpp b/deps/wrappers/qt-wrappers.hpp deleted file mode 100644 index 487228e..0000000 --- a/deps/wrappers/qt-wrappers.hpp +++ /dev/null @@ -1,101 +0,0 @@ -/****************************************************************************** - Copyright (C) 2023 by Lain Bailey - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -******************************************************************************/ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define QT_UTF8(str) QString::fromUtf8(str, -1) -#define QT_TO_UTF8(str) str.toUtf8().constData() -#define MAX_LABEL_LENGTH 80 - -class QDataStream; -class QComboBox; -class QWidget; -class QLayout; -class QString; -class QLabel; -class QToolBar; - -class OBSMessageBox : QObject { - Q_OBJECT - public: - static QMessageBox::StandardButton question( - QWidget *parent, const QString &title, const QString &text, - QMessageBox::StandardButtons buttons = QMessageBox::StandardButtons(QMessageBox::Yes | - QMessageBox::No), - QMessageBox::StandardButton defaultButton = QMessageBox::NoButton); - static void information(QWidget *parent, const QString &title, const QString &text); - static void warning(QWidget *parent, const QString &title, const QString &text, - bool enableRichText = false); - static void critical(QWidget *parent, const QString &title, const QString &text); -}; - -void OBSErrorBox(QWidget *parent, const char *msg, ...); - -uint32_t TranslateQtKeyboardEventModifiers(Qt::KeyboardModifiers mods); - -QDataStream &operator<<(QDataStream &out, - const std::vector> &signal_vec); -QDataStream &operator>>(QDataStream &in, std::vector> &signal_vec); -QDataStream &operator<<(QDataStream &out, const OBSScene &scene); -QDataStream &operator>>(QDataStream &in, OBSScene &scene); -QDataStream &operator<<(QDataStream &out, const OBSSource &source); -QDataStream &operator>>(QDataStream &in, OBSSource &source); - -QThread *CreateQThread(std::function func); - -void ExecuteFuncSafeBlock(std::function func); -void ExecuteFuncSafeBlockMsgBox(std::function func, const QString &title, - const QString &text); - -/* allows executing without message boxes if starting up, otherwise with a - * message box */ -void EnableThreadedMessageBoxes(bool enable); -void ExecThreadedWithoutBlocking(std::function func, const QString &title, - const QString &text); - -void DeleteLayout(QLayout *layout); - -static inline Qt::ConnectionType WaitConnection() { - return QThread::currentThread() == qApp->thread() ? Qt::DirectConnection - : Qt::BlockingQueuedConnection; -} - -bool LineEditCanceled(QEvent *event); -bool LineEditChanged(QEvent *event); - -void SetComboItemEnabled(QComboBox *c, int idx, bool enabled); - -void setClasses(QWidget *widget, const QString &newClasses); - -QString SelectDirectory(QWidget *parent, QString title, QString path); -QString SaveFile(QWidget *parent, QString title, QString path, QString extensions); -QString OpenFile(QWidget *parent, QString title, QString path, QString extensions); -QStringList OpenFiles(QWidget *parent, QString title, QString path, QString extensions); - -void TruncateLabel(QLabel *label, QString newText, int length = MAX_LABEL_LENGTH); - -void RefreshToolBarStyling(QToolBar *toolBar); diff --git a/package/windows/build-installer.ps1 b/package/windows/build-installer.ps1 index 14d1dd0..77ace8a 100644 --- a/package/windows/build-installer.ps1 +++ b/package/windows/build-installer.ps1 @@ -67,19 +67,26 @@ if (!(Test-Path $OutputDir)) { # Create temporary NSI file with version substitution $NSITemplate = Get-Content "installer.nsi" -Raw -# Extract numeric version for NSIS (remove any suffix like -stage, -beta, etc.) -$NumericVersion = $Version -replace '-.*$', '' -# Ensure we have at least 3 parts for the version (X.X.X format) +# Extract numeric version for NSIS +# - remove any leading 'v' +# - remove any suffix like -stage, -beta, etc. +$NumericVersion = $Version -replace '^v', '' -replace '-.*$', '' +# Ensure we have exactly 4 numeric parts for VIProductVersion $VersionParts = $NumericVersion.Split('.') -while ($VersionParts.Length -lt 3) { +while ($VersionParts.Length -lt 4) { $VersionParts += "0" } -$CleanVersion = $VersionParts[0..2] -join '.' +if ($VersionParts.Length -gt 4) { + $VersionParts = $VersionParts[0..3] +} +$CleanVersion = $VersionParts -join '.' -# Replace PRODUCT_VERSION for NSIS version info (must be X.X.X format) +# Replace PRODUCT_VERSION with 4-part numeric version for NSIS version info $NSIContent = $NSITemplate -replace '!define PRODUCT_VERSION "1\.0\.0"', "!define PRODUCT_VERSION `"$CleanVersion`"" -# Replace the OutFile to use the full version (including suffix) for the installer filename -$NSIContent = $NSIContent -replace 'OutFile "17liveOBSPlugin-windows-v\$\{PRODUCT_VERSION\}\.exe"', "OutFile `"17liveOBSPlugin-windows-v$Version.exe`"" +# Replace the OutFile to use the full version tag for the installer filename +# Normalize to ensure single leading 'v' +$VersionTag = if ($Version -match '^v') { $Version } else { "v$Version" } +$NSIContent = $NSIContent -replace 'OutFile "17liveOBSPlugin-windows-v\$\{PRODUCT_VERSION\}\.exe"', "OutFile `"17liveOBSPlugin-windows-$VersionTag.exe`"" $TempNSI = "installer_temp.nsi" $NSIContent | Out-File -FilePath $TempNSI -Encoding UTF8 @@ -105,6 +112,30 @@ try { Move-Item $InstallerName (Join-Path $OutputDir $InstallerName) -Force Write-Host "✓ Installer moved to: $(Join-Path $OutputDir $InstallerName)" -ForegroundColor Green } + + # Package non-installer zip from rundir contents + Write-Host "Packaging non-installer zip..." -ForegroundColor Yellow + $ZipFolderName = "17liveOBSPlugin-windows-$VersionTag" + $StagingRoot = Join-Path $OutputDir $ZipFolderName + if (Test-Path $StagingRoot) { + Remove-Item $StagingRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $StagingRoot -Force | Out-Null + + # Copy the contents of rundir into the staging root (no extra nested directory) + if (!(Test-Path $BuildDir -PathType Container)) { + Write-Error "BuildDir not found for zip packaging: $BuildDir" + exit 1 + } + Copy-Item -Path "$BuildDir\*" -Destination $StagingRoot -Recurse -Force + + $ZipName = "17liveOBSPlugin-windows-$VersionTag-non-installer.zip" + $ZipPath = Join-Path $OutputDir $ZipName + if (Test-Path $ZipPath) { + Remove-Item $ZipPath -Force + } + Compress-Archive -Path $StagingRoot -DestinationPath $ZipPath + Write-Host "✓ Non-installer zip created: $ZipPath" -ForegroundColor Green } else { Write-Error "NSIS build failed with exit code: $($Process.ExitCode)" exit 1 @@ -124,7 +155,7 @@ Write-Host "Build completed successfully!" -ForegroundColor Green Write-Host "Installer location: $(Join-Path $OutputDir "17liveOBSPlugin-windows-v$Version.exe")" -ForegroundColor Cyan # Display file information -$InstallerPath = Join-Path $OutputDir "17liveOBSPlugin-windows-v$Version.exe" +$InstallerPath = Join-Path $OutputDir "17liveOBSPlugin-windows-$Version.exe" if (Test-Path $InstallerPath) { $FileInfo = Get-Item $InstallerPath Write-Host "" @@ -132,4 +163,4 @@ if (Test-Path $InstallerPath) { Write-Host " File: $($FileInfo.Name)" Write-Host " Size: $([math]::Round($FileInfo.Length / 1MB, 2)) MB" Write-Host " Created: $($FileInfo.CreationTime)" -} \ No newline at end of file +} diff --git a/package/windows/installer.nsi b/package/windows/installer.nsi index 546c9cc..d24e7d6 100644 --- a/package/windows/installer.nsi +++ b/package/windows/installer.nsi @@ -2,7 +2,7 @@ ; Based on the configuration from GitHub Actions workflow !define PRODUCT_NAME "17Live OBS Plugin" -!define PRODUCT_VERSION "1.0.0" ; This will be replaced by build script +!define PRODUCT_VERSION "1.0.0" ; This will be replaced by build script (supports four-part version e.g. 1.1.4.4) !define PRODUCT_PUBLISHER "17Live Limited" !define PRODUCT_WEB_SITE "https://17.live" !define PACKAGE_ID "OneSevenLive.ObsPlugin" @@ -28,6 +28,7 @@ ShowInstDetails show ShowUnInstDetails show RequestExecutionLevel admin + ; Interface Settings !define MUI_ABORTWARNING !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" @@ -62,7 +63,7 @@ RequestExecutionLevel admin !insertmacro MUI_LANGUAGE "TradChinese" ; Version Information -VIProductVersion "${PRODUCT_VERSION}.0" +VIProductVersion "${PRODUCT_VERSION}" VIAddVersionKey /LANG=${LANG_ENGLISH} "ProductName" "${PRODUCT_NAME}" VIAddVersionKey /LANG=${LANG_ENGLISH} "Comments" "17Live OBS Plugin for streaming integration" VIAddVersionKey /LANG=${LANG_ENGLISH} "CompanyName" "${PRODUCT_PUBLISHER}" diff --git a/resources.qrc b/resources.qrc index 277b4e0..c0aacca 100644 --- a/resources.qrc +++ b/resources.qrc @@ -5,15 +5,21 @@ resources/17live-logo-white.svg resources/edit.svg resources/delete.svg + resources/play.svg + resources/stop.svg + resources/settings.svg + resources/trash.svg resources/show-password.svg resources/hide-password.svg resources/question.svg resources/arrow-down.svg resources/arrow-up.svg resources/alert.svg + resources/alert-white.svg resources/exclaimark.svg resources/close.svg resources/circle-check.svg + resources/baobaobi.svg resources/user_images/150a2431-f728-4d8e-8af8-e7c75e3b5c7f.png resources/user_images/37bd32cb-f100-4155-9248-84a24d04ccad.png resources/user_images/3dc9b982-d280-46af-82af-9443e646969f.png diff --git a/resources/alert-white.svg b/resources/alert-white.svg new file mode 100644 index 0000000..46906ae --- /dev/null +++ b/resources/alert-white.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/resources/baobaobi.svg b/resources/baobaobi.svg new file mode 100644 index 0000000..07375e2 --- /dev/null +++ b/resources/baobaobi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/play.svg b/resources/play.svg new file mode 100644 index 0000000..2076779 --- /dev/null +++ b/resources/play.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/resources/settings.svg b/resources/settings.svg new file mode 100644 index 0000000..ee9ebee --- /dev/null +++ b/resources/settings.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/resources/stop.svg b/resources/stop.svg new file mode 100644 index 0000000..28ec6d3 --- /dev/null +++ b/resources/stop.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/resources/trash.svg b/resources/trash.svg new file mode 100644 index 0000000..4c7ced1 --- /dev/null +++ b/resources/trash.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/src/17live/CefDummy.cpp b/src/17live/CefDummy.cpp deleted file mode 100644 index d2cfa99..0000000 --- a/src/17live/CefDummy.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "CefDummy.hpp" - -#include -#include -#include // For os_event_t, etc. -#include - -#include -#include // For DStr - -#include "plugin-support.h" - -static bool cef_initialized = false; -static os_event_t *cef_started_event = nullptr; -static obs_source_t *dummy_source = nullptr; - -// Function to initialize CEF - -static bool create_dummy_browser_source(void) { - if (dummy_source) { - blog(LOG_WARNING, "[obs-17live] Dummy browser source already exists."); - return true; - } - - // Get browser source type (ensure obs-browser plugin is loaded) - const char *source_id = "browser_source"; - - obs_data_t *settings = obs_get_source_defaults(source_id); - - // Create source - dummy_source = obs_source_create(source_id, "DummyBrowser", settings, nullptr); - if (!dummy_source) { - blog(LOG_ERROR, "[obs-17live] Failed to create browser source"); - obs_data_release(settings); - return false; - } - - blog(LOG_INFO, "[obs-17live] Browser source created successfully"); - - obs_data_release(settings); - return true; -} - -static bool initialize_cef() { - if (cef_initialized) - return true; - - obs_log(LOG_INFO, "Initializing CEF..."); - - if (!create_dummy_browser_source()) { - obs_log(LOG_ERROR, "Failed to create dummy browser source."); - return false; - } - - cef_initialized = true; - - obs_log(LOG_INFO, "CEF initialized successfully."); - return true; -} - -// Function to shutdown CEF -static void shutdown_cef() { - if (!cef_initialized) - return; - obs_log(LOG_INFO, "Shutting down CEF..."); - - obs_source_release(dummy_source); - dummy_source = nullptr; - - cef_initialized = false; - obs_log(LOG_INFO, "CEF shutdown complete."); -} - -// Called when the OBS frontend is available -static void obs_frontend_event_callback(enum obs_frontend_event event, void *private_data) { - UNUSED_PARAMETER(private_data); - if (event == OBS_FRONTEND_EVENT_FINISHED_LOADING) { - // Initialize CEF (if not already done) - // It's better to initialize CEF early, perhaps in obs_module_load, but ensure it's on the - // correct thread. For now, deferring until first use or here. - if (!cef_initialized) { - // Running CEF initialization on the main UI thread is crucial. - // OBS might call this callback on the UI thread. - // If not, CefInitialize needs to be posted to the UI thread. - // For simplicity, assuming this callback is on an appropriate thread. - // A more robust solution would use a dedicated thread for CEF message loop and - // initialization. - os_event_init(&cef_started_event, OS_EVENT_TYPE_MANUAL); - if (!initialize_cef()) { - obs_log(LOG_ERROR, "CEF View: Failed to initialize CEF during frontend load."); - } else { - // If CEF needs its own message loop and is not integrated with Qt's loop: - // std::thread cef_message_loop_thread([](){ - // CefRunMessageLoop(); - // }); - // cef_message_loop_thread.detach(); - } - } - } -} - -// Called by OBS when the module is loaded -void cef_view_load(void) { - obs_log(LOG_INFO, "CEF View plugin loading..."); - obs_frontend_add_event_callback(obs_frontend_event_callback, nullptr); -} - -// Called by OBS when the module is unloaded -void cef_view_unload(void) { - obs_log(LOG_INFO, "CEF View plugin unloading..."); - shutdown_cef(); - if (cef_started_event) { - os_event_destroy(cef_started_event); - cef_started_event = nullptr; - } -} diff --git a/src/17live/CefDummy.hpp b/src/17live/CefDummy.hpp deleted file mode 100644 index 9b8c08b..0000000 --- a/src/17live/CefDummy.hpp +++ /dev/null @@ -1,4 +0,0 @@ -#pragma once - -void cef_view_load(void); -void cef_view_unload(void); diff --git a/src/17live/OneSevenLiveConfigManager.cpp b/src/17live/OneSevenLiveConfigManager.cpp index 87148c7..dfc3cb6 100644 --- a/src/17live/OneSevenLiveConfigManager.cpp +++ b/src/17live/OneSevenLiveConfigManager.cpp @@ -118,6 +118,21 @@ bool OneSevenLiveConfigManager::getConfigValue(const std::string &key, std::stri return true; } +qint64 OneSevenLiveConfigManager::getRoomID() { + if (!initialized) { + return 0; + } + + // Read operation uses shared lock + std::shared_lock lock(configMutex); + + if (!config) { + return 0; + } + + return static_cast(config_get_uint(config, service, "RoomID")); +} + bool OneSevenLiveConfigManager::getLoginData(OneSevenLiveLoginData &loginData) { if (!initialized) { return false; @@ -690,3 +705,390 @@ bool OneSevenLiveConfigManager::loadGifts(Json &gifts) { return false; } } + +bool OneSevenLiveConfigManager::setTwitchTokens(const QString &accessToken, + qint64 fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + // Convert to std::string and maintain reference + std::string accessTokenStr = accessToken.toStdString(); + std::string fetchedStr = std::to_string(static_cast(fetchedAtEpochSec)); + + config_set_string(config, service, "TwitchAccessToken", accessTokenStr.c_str()); + config_set_string(config, service, "TwitchAccessTokenFetchedAt", fetchedStr.c_str()); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to save Twitch access token"); + return false; + } + + obs_log(LOG_INFO, "Twitch access token saved successfully"); + return true; +} + +bool OneSevenLiveConfigManager::getTwitchTokens(QString &accessToken, qint64 &fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Read operation uses shared lock + std::shared_lock lock(configMutex); + + if (!config) { + return false; + } + + const char *accessTokenChar = config_get_string(config, service, "TwitchAccessToken"); + const char *fetchedChar = config_get_string(config, service, "TwitchAccessTokenFetchedAt"); + + if (!accessTokenChar) { + return false; + } + + accessToken = QString::fromUtf8(accessTokenChar); + if (fetchedChar) { + try { + fetchedAtEpochSec = static_cast(std::stoll(fetchedChar)); + } catch (...) { + fetchedAtEpochSec = 0; + } + } else { + fetchedAtEpochSec = 0; + } + + return true; +} + +bool OneSevenLiveConfigManager::clearTwitchTokens() { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + config_set_string(config, service, "TwitchAccessToken", ""); + config_set_string(config, service, "TwitchAccessTokenFetchedAt", ""); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to clear Twitch tokens"); + return false; + } + + obs_log(LOG_INFO, "Twitch access token cleared successfully"); + return true; +} + +bool OneSevenLiveConfigManager::setYouTubeAccessToken(const QString &accessToken, int expiresInSec, + qint64 fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + std::string accessTokenStr = accessToken.toStdString(); + std::string fetchedStr = std::to_string(static_cast(fetchedAtEpochSec)); + std::string expiresStr = std::to_string(static_cast(expiresInSec)); + + config_set_string(config, service, "YouTubeAccessToken", accessTokenStr.c_str()); + config_set_string(config, service, "YouTubeAccessTokenFetchedAt", fetchedStr.c_str()); + config_set_string(config, service, "YouTubeAccessTokenExpiresIn", expiresStr.c_str()); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to save YouTube access token"); + return false; + } + + { + QString tok = QString::fromUtf8(accessTokenStr.c_str()); + QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + obs_log(LOG_INFO, "YouTube access token saved successfully at %s token(masked)=%s", + configPath.c_str(), masked.toUtf8().constData()); + } + return true; +} + +bool OneSevenLiveConfigManager::getYouTubeAccessToken(QString &accessToken, int &expiresInSec, + qint64 &fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Read operation uses shared lock + std::shared_lock lock(configMutex); + + if (!config) { + return false; + } + + const char *accessTokenChar = config_get_string(config, service, "YouTubeAccessToken"); + const char *fetchedChar = config_get_string(config, service, "YouTubeAccessTokenFetchedAt"); + const char *expiresChar = config_get_string(config, service, "YouTubeAccessTokenExpiresIn"); + + if (!accessTokenChar) { + return false; + } + + accessToken = QString::fromUtf8(accessTokenChar); + + if (fetchedChar) { + try { + fetchedAtEpochSec = static_cast(std::stoll(fetchedChar)); + } catch (...) { + fetchedAtEpochSec = 0; + } + } else { + fetchedAtEpochSec = 0; + } + + if (expiresChar) { + try { + expiresInSec = static_cast(std::stoi(expiresChar)); + } catch (...) { + expiresInSec = 0; + } + } else { + expiresInSec = 0; + } + + return true; +} + +bool OneSevenLiveConfigManager::clearYouTubeAccessToken() { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + config_set_string(config, service, "YouTubeAccessToken", ""); + config_set_string(config, service, "YouTubeAccessTokenFetchedAt", ""); + config_set_string(config, service, "YouTubeAccessTokenExpiresIn", ""); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to clear YouTube access token"); + return false; + } + + obs_log(LOG_INFO, "YouTube access token cleared successfully"); + return true; +} + +bool OneSevenLiveConfigManager::getYouTubeRefreshToken(QString &refreshToken, int &expiresInSec, + qint64 &fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Read operation uses shared lock + std::shared_lock lock(configMutex); + + if (!config) { + return false; + } + + const char *refreshTokenChar = config_get_string(config, service, "YouTubeRefreshToken"); + const char *fetchedChar = config_get_string(config, service, "YouTubeRefreshTokenFetchedAt"); + const char *expiresChar = config_get_string(config, service, "YouTubeRefreshTokenExpiresIn"); + if (!refreshTokenChar) { + return false; + } + + refreshToken = QString::fromUtf8(refreshTokenChar); + + if (fetchedChar) { + try { + fetchedAtEpochSec = static_cast(std::stoll(fetchedChar)); + } catch (...) { + fetchedAtEpochSec = 0; + } + } else { + fetchedAtEpochSec = 0; + } + + if (expiresChar) { + try { + expiresInSec = static_cast(std::stoi(expiresChar)); + } catch (...) { + expiresInSec = 0; + } + } else { + expiresInSec = 0; + } + return true; +} + +bool OneSevenLiveConfigManager::clearYouTubeRefreshToken() { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + config_set_string(config, service, "YouTubeRefreshToken", ""); + config_set_string(config, service, "YouTubeRefreshTokenFetchedAt", ""); + config_set_string(config, service, "YouTubeRefreshTokenExpiresIn", ""); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to clear YouTube refresh token"); + return false; + } + + obs_log(LOG_INFO, "YouTube refresh token cleared successfully"); + return true; +} + +bool OneSevenLiveConfigManager::setTwitchUserInfo(const QString &userId, const QString &login, + const QString &displayName, + const QString &profileImageUrl, + const QString &email, int viewCount) { + if (!initialized) { + return false; + } + + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + std::string userIdStr = userId.toStdString(); + std::string loginStr = login.toStdString(); + std::string displayNameStr = displayName.toStdString(); + std::string profileImageUrlStr = profileImageUrl.toStdString(); + std::string emailStr = email.toStdString(); + + config_set_string(config, service, "TwitchUserId", userIdStr.c_str()); + config_set_string(config, service, "TwitchLogin", loginStr.c_str()); + config_set_string(config, service, "TwitchDisplayName", displayNameStr.c_str()); + config_set_string(config, service, "TwitchProfileImageUrl", profileImageUrlStr.c_str()); + config_set_string(config, service, "TwitchEmail", emailStr.c_str()); + config_set_int(config, service, "TwitchViewCount", viewCount); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to save Twitch user info config"); + return false; + } + + obs_log(LOG_INFO, "Twitch user info saved - User ID: %s, Login: %s", userIdStr.c_str(), + loginStr.c_str()); + return true; +} + +bool OneSevenLiveConfigManager::getTwitchUserInfo(QString &userId, QString &login, + QString &displayName, QString &profileImageUrl, + QString &email, int &viewCount) { + if (!initialized) { + return false; + } + + std::shared_lock lock(configMutex); + + if (!config) { + return false; + } + + const char *userIdChar = config_get_string(config, service, "TwitchUserId"); + const char *loginChar = config_get_string(config, service, "TwitchLogin"); + const char *displayNameChar = config_get_string(config, service, "TwitchDisplayName"); + const char *profileImageUrlChar = config_get_string(config, service, "TwitchProfileImageUrl"); + const char *emailChar = config_get_string(config, service, "TwitchEmail"); + + if (!userIdChar || !loginChar) { + return false; // Required fields missing + } + + userId = QString::fromUtf8(userIdChar); + login = QString::fromUtf8(loginChar); + displayName = displayNameChar ? QString::fromUtf8(displayNameChar) : ""; + profileImageUrl = profileImageUrlChar ? QString::fromUtf8(profileImageUrlChar) : ""; + email = emailChar ? QString::fromUtf8(emailChar) : ""; + viewCount = config_get_int(config, service, "TwitchViewCount"); + + return true; +} + +bool OneSevenLiveConfigManager::clearTwitchUserInfo() { + if (!initialized) { + return false; + } + + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + config_set_string(config, service, "TwitchUserId", ""); + config_set_string(config, service, "TwitchLogin", ""); + config_set_string(config, service, "TwitchDisplayName", ""); + config_set_string(config, service, "TwitchProfileImageUrl", ""); + config_set_string(config, service, "TwitchEmail", ""); + config_set_int(config, service, "TwitchViewCount", 0); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to save config"); + return false; + } + + return true; +} + +bool OneSevenLiveConfigManager::setYouTubeRefreshToken(const QString &refreshToken, + int expiresInSec, qint64 fetchedAtEpochSec) { + if (!initialized) { + return false; + } + + // Write operation uses exclusive lock + std::unique_lock lock(configMutex); + + if (!config) { + return false; + } + + std::string refreshTokenStr = refreshToken.toStdString(); + std::string fetchedStr = std::to_string(static_cast(fetchedAtEpochSec)); + std::string expiresStr = std::to_string(static_cast(expiresInSec)); + + config_set_string(config, service, "YouTubeRefreshToken", refreshTokenStr.c_str()); + config_set_string(config, service, "YouTubeRefreshTokenFetchedAt", fetchedStr.c_str()); + config_set_string(config, service, "YouTubeRefreshTokenExpiresIn", expiresStr.c_str()); + + if (config_save(config) < 0) { + obs_log(LOG_ERROR, "Failed to save YouTube refresh token"); + return false; + } + + obs_log(LOG_INFO, "YouTube refresh token saved successfully"); + return true; +} diff --git a/src/17live/OneSevenLiveConfigManager.hpp b/src/17live/OneSevenLiveConfigManager.hpp index 0428ab6..c7eeae1 100644 --- a/src/17live/OneSevenLiveConfigManager.hpp +++ b/src/17live/OneSevenLiveConfigManager.hpp @@ -43,6 +43,9 @@ class OneSevenLiveConfigManager { bool getConfigValue(const std::string &key, std::string &value); + // Get current room ID + qint64 getRoomID(); + bool saveLiveConfig(const OneSevenLiveStreamInfo &streamInfo); bool loadAllLiveConfig(std::vector &streamInfo); bool saveAllLiveConfig(const std::vector &streamInfo); @@ -62,6 +65,30 @@ class OneSevenLiveConfigManager { bool saveGifts(const json &gifts); bool loadGifts(json &gifts); + // Twitch token management + bool setTwitchTokens(const QString &accessToken, qint64 fetchedAtEpochSec); + bool getTwitchTokens(QString &accessToken, qint64 &fetchedAtEpochSec); + bool clearTwitchTokens(); + + // YouTube token management + bool setYouTubeAccessToken(const QString &accessToken, int expiresInSec, + qint64 fetchedAtEpochSec); + bool getYouTubeAccessToken(QString &accessToken, int &expiresInSec, qint64 &fetchedAtEpochSec); + bool clearYouTubeAccessToken(); + + bool setYouTubeRefreshToken(const QString &refreshToken, int expiresInSec, + qint64 fetchedAtEpochSec); + bool getYouTubeRefreshToken(QString &refreshToken, int &expiresInSec, + qint64 &fetchedAtEpochSec); + bool clearYouTubeRefreshToken(); + + // Twitch user information management + bool setTwitchUserInfo(const QString &userId, const QString &login, const QString &displayName, + const QString &profileImageUrl, const QString &email, int viewCount); + bool getTwitchUserInfo(QString &userId, QString &login, QString &displayName, + QString &profileImageUrl, QString &email, int &viewCount); + bool clearTwitchUserInfo(); + private: bool initialized = false; diff --git a/src/17live/OneSevenLiveCoreManager.cpp b/src/17live/OneSevenLiveCoreManager.cpp index 3e56609..342c22e 100644 --- a/src/17live/OneSevenLiveCoreManager.cpp +++ b/src/17live/OneSevenLiveCoreManager.cpp @@ -6,12 +6,15 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include #include #include #include @@ -19,19 +22,36 @@ #include #include +#include "../diag/ui/DiagnosticsDialog.hpp" #include "OneSevenLiveConfigManager.hpp" #include "OneSevenLiveHttpServer.hpp" #include "OneSevenLiveLoginDialog.hpp" #include "OneSevenLiveMenuManager.hpp" -#include "OneSevenLiveRockZoneDock.hpp" -#include "OneSevenLiveStreamListDock.hpp" -#include "OneSevenLiveStreamingDock.hpp" #include "OneSevenLiveUpdateManager.hpp" -#include "QCefView.hpp" #include "api/OneSevenLiveApiWrappers.hpp" +#include "chat/OneSevenLiveChatMessageHandler.hpp" +#include "chat/OneSevenLiveChatWidget.hpp" +#include "multi-rtmp/OneSevenLiveMultiRtmpManager.hpp" +#include "multi-rtmp/ui/OneSevenLiveMultiRtmpDock.hpp" #include "plugin-support.h" +#include "preview/OneSevenLivePreviewDock.hpp" +#include "rockzone/OneSevenLiveRockZoneDock.hpp" +#include "streaming/OneSevenLiveStreamManager.hpp" +#include "streaming/OneSevenLiveStreamingDock.hpp" +#include "streamlist/OneSevenLiveStreamListDock.hpp" +#include "twitch/OneSevenLiveTwitchAuth.hpp" #include "utility/Common.hpp" #include "utility/Meta.hpp" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WsMessage.hpp" +#include "youtube/OneSevenLiveYouTubeAuth.hpp" +// Chat clients + +#include "api/OneSevenLiveAblyChatClient.hpp" +#include "twitch/OneSevenLiveTwitchChatClient.hpp" +#include "websocket/WebsocketUtils.hpp" +#include "youtube/OneSevenLiveYouTubeChatClient.hpp" +#include "youtube/OneSevenLiveYouTubeClient.hpp" using Json = nlohmann::json; using namespace std; @@ -52,8 +72,15 @@ OneSevenLiveCoreManager& OneSevenLiveCoreManager::getInstance(QMainWindow* mainW return *instance; } +void OneSevenLiveCoreManager::destroyInstance() { + if (instance) { + delete instance; + instance = nullptr; + } +} + OneSevenLiveCoreManager::OneSevenLiveCoreManager(QMainWindow* mainWindow_) - : mainWindow(mainWindow_), initialized(false) {} + : mainWindow(mainWindow_), initialized(false), multiRtmpDockFirstLoad(true) {} OneSevenLiveCoreManager::~OneSevenLiveCoreManager() { // Ensure shutdown is called before destruction @@ -70,50 +97,66 @@ bool OneSevenLiveCoreManager::initialize() { obs_log(LOG_INFO, "[17Live Core] Initializing OneSevenLiveCoreManager..."); - try { - // Run network diagnostics to check API connectivity - obs_log(LOG_INFO, "[17Live Core] Running startup network diagnostics..."); - NetworkDiagnostics::runStartupDiagnostics(ONESEVENLIVE_API_URL); - - // Initialize and start HTTP server - // "html" is the path relative to obs_get_module_data_path() - httpServer_ = std::make_unique("localhost", 0, "html/chat"); - if (!httpServer_) { - obs_log(LOG_ERROR, "[17Live Core] Failed to create HTTP server instance"); - return false; - } + m_cancelFlag.store(false); - if (!httpServer_->start()) { - obs_log(LOG_ERROR, "[17Live Core] Failed to start HTTP server"); - // Decide whether to interrupt the entire initialization due to HTTP server startup - // failure based on requirements return false; - } else { - obs_log(LOG_INFO, "[17Live Core] HTTP server started successfully"); - } + // Run network diagnostics to check API connectivity + obs_log(LOG_INFO, "[17Live Core] Running startup network diagnostics..."); + NetworkDiagnostics::runStartupDiagnostics(ONESEVENLIVE_API_URL); - // Initialize configuration manager - configManager = std::make_unique(); - if (!configManager) { - obs_log(LOG_ERROR, "[17Live Core] Failed to create config manager instance"); - return false; - } + // Initialize and start HTTP server + // "html" is the path relative to obs_get_module_data_path() + httpServer_ = + std::make_unique("localhost", 0, "html/chat", "17Live HTTP Server"); + if (!httpServer_) { + obs_log(LOG_ERROR, "[17Live Core] Failed to create HTTP server instance"); + return false; + } - if (!configManager->initialize()) { - obs_log(LOG_ERROR, "[17Live Core] Failed to initialize config manager"); - return false; - } - } catch (const std::bad_alloc& e) { - obs_log(LOG_ERROR, "[17Live Core] Memory allocation failed during initialization: %s", - e.what()); + if (!httpServer_->start()) { + obs_log(LOG_ERROR, "[17Live Core] Failed to start HTTP server"); + // Decide whether to interrupt the entire initialization due to HTTP server startup + // failure based on requirements return false; + } else { + obs_log(LOG_INFO, "[17Live Core] HTTP server started successfully"); + } + + // Initialize and start WebSocket server + websocketServer_ = std::make_shared("localhost", 0); + if (!websocketServer_) { + obs_log(LOG_ERROR, "[17Live Core] Failed to create WebSocket server instance"); return false; - } catch (const std::exception& e) { - obs_log(LOG_ERROR, "[17Live Core] Exception during initialization: %s", e.what()); + } + + if (!websocketServer_->start()) { + obs_log(LOG_ERROR, "[17Live Core] Failed to start WebSocket server"); + // Continue initialization even if WebSocket server fails + } else { + obs_log(LOG_INFO, "[17Live Core] WebSocket server started successfully on port %d", + websocketServer_->getPort()); + } + + // Set up WebSocket server callbacks + websocketServer_->setMessageCallback(std::bind(&OneSevenLiveCoreManager::handleWebsocketMessage, + this, std::placeholders::_1, + std::placeholders::_2)); + + websocketServer_->setConnectionCallback( + std::bind(&OneSevenLiveCoreManager::handleWebsocketConnectionChanged, this, + std::placeholders::_1, std::placeholders::_2)); + + // Initialize configuration manager + configManager = std::make_unique(); + if (!configManager) { + obs_log(LOG_ERROR, "[17Live Core] Failed to create config manager instance"); return false; - } catch (...) { - obs_log(LOG_ERROR, "[17Live Core] Unknown exception during initialization"); + } + + if (!configManager->initialize()) { + obs_log(LOG_ERROR, "[17Live Core] Failed to initialize config manager"); return false; } + // Initialize API wrapper before creating stream manager OneSevenLiveLoginData loginData; configManager->getLoginData(loginData); @@ -122,15 +165,80 @@ bool OneSevenLiveCoreManager::initialize() { if (!loginData.jwtAccessToken.isEmpty()) { apiWrapper = std::make_unique(loginData.jwtAccessToken.toStdString()); + apiWrapper->setCancelFlag(&m_cancelFlag); isLogin = checkLoginStatus(); } - // if not login, reinitialize apiWrapper + // if not login, initialize apiWrapper without token if (!isLogin) { apiWrapper = std::make_unique(); + apiWrapper->setCancelFlag(&m_cancelFlag); } + // Instantiate auth handlers + twitchAuth = std::make_unique(this); + // youtubeAuth = std::make_unique(this); + + // Load tokens from config and schedule checks/refreshes + { + // Twitch: load access token for status check + QString twAccess; + qint64 twFetched{0}; + if (configManager->getTwitchTokens(twAccess, twFetched)) { + if (!twAccess.isEmpty()) { + twitchAuth->setTokens(twAccess, QString()); + obs_log(LOG_INFO, "[17Live Core] Loaded Twitch access token from config"); + } + } + } + + // { + // // YouTube: load access and refresh tokens + // QString ytAccess; + // int ytExpiresIn{0}; + // qint64 ytFetchedAt{0}; + // const bool hasAccess = + // configManager->getYouTubeAccessToken(ytAccess, ytExpiresIn, ytFetchedAt) && + // !ytAccess.isEmpty(); + // + // QString ytRefresh; + // int ytRefreshExpiresIn{0}; + // qint64 ytRefreshFetchedAt{0}; + // const bool hasRefresh = configManager->getYouTubeRefreshToken(ytRefresh, + // ytRefreshExpiresIn, + // ytRefreshFetchedAt) && + // !ytRefresh.isEmpty(); + // + // const qint64 nowEpoch = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + // + // if (hasAccess) { + // youtubeAuth->setAccessToken(ytAccess); + // // Schedule auto refresh; if access already expired, this will attempt immediate + // refresh youtubeAuth->scheduleAutoRefresh(ytExpiresIn, ytFetchedAt, + // ytRefreshExpiresIn, + // ytRefreshFetchedAt); + // } else if (hasRefresh) { + // const bool hasExpiry = ytRefreshExpiresIn > 0; + // const bool notExpired = + // hasExpiry ? (nowEpoch < ytRefreshFetchedAt + ytRefreshExpiresIn) : true; + // if (notExpired) { + // obs_log(LOG_INFO, + // "[17Live Core] No YouTube access token; refreshing using refresh token"); + // QTimer::singleShot(0, youtubeAuth.get(), + // &OneSevenLiveYouTubeAuth::refreshAccessTokenAsync); + // } else { + // obs_log(LOG_INFO, + // "[17Live Core] YouTube refresh token expired; clearing stored tokens"); + // configManager->clearYouTubeAccessToken(); + // configManager->clearYouTubeRefreshToken(); + // } + // } + // } + + // Load gifts from saved config into memory map for fast lookup + loadGiftsFromConfig(); + // Initialize menu manager menuManager = std::make_unique(mainWindow); if (!menuManager) { @@ -156,17 +264,30 @@ bool OneSevenLiveCoreManager::initialize() { QObject::connect(menuManager.get(), &OneSevenLiveMenuManager::rockZoneClicked, this, &OneSevenLiveCoreManager::handleRockZoneClicked); + QObject::connect(menuManager.get(), &OneSevenLiveMenuManager::multiRtmpClicked, this, + &OneSevenLiveCoreManager::handleMultiRtmpClicked); + + QObject::connect(menuManager.get(), &OneSevenLiveMenuManager::previewDockClicked, this, + &OneSevenLiveCoreManager::handlePreviewDockClicked); + QObject::connect(menuManager.get(), &OneSevenLiveMenuManager::checkUpdateClicked, this, &OneSevenLiveCoreManager::handleCheckUpdateClicked); + QObject::connect(menuManager.get(), &OneSevenLiveMenuManager::diagnosticsClicked, this, + &OneSevenLiveCoreManager::handleDiagnosticsClicked); + // Initialize update manager updateManager = new OneSevenLiveUpdateManager(this); // Connect update manager signals + QPointer self = this; QObject::connect( updateManager, &OneSevenLiveUpdateManager::updateAvailable, this, - [this](const QString& latestVersion, const QJsonArray& assets) { - QMessageBox msgBox(mainWindow); + [self](const QString& latestVersion, const QJsonArray& assets) { + if (!self) + return; + UNUSED_PARAMETER(assets); + QMessageBox msgBox(self->mainWindow); msgBox.setWindowTitle(obs_module_text("Update.NewVersionFound")); msgBox.setText( QString(obs_module_text("Update.NewVersionFound.Message")).arg(latestVersion)); @@ -176,54 +297,19 @@ bool OneSevenLiveCoreManager::initialize() { if (msgBox.exec() == QMessageBox::Yes) { // open download page obs_module_text("Menu.CheckUpdate.Url") QDesktopServices::openUrl(QUrl(obs_module_text("Menu.CheckUpdate.Url"))); - - // QString systemInfo = updateManager->getSystemInfo(); - // QString downloadUrl; - // QString fileName; - - // for (QJsonValue assetValue : assets) { - // QJsonObject asset = assetValue.toObject(); - // QString assetName = asset["name"].toString(); - - // obs_log(LOG_INFO, "Asset name: %s", assetName.toStdString().c_str()); - // obs_log(LOG_INFO, "systemInfo: %s", systemInfo.toStdString().c_str()); - - // if (systemInfo.contains("macOS")) { - // if (systemInfo.contains("arm64") && - // assetName.contains("macAppleSilicon")) { - // downloadUrl = asset["browser_download_url"].toString(); - // fileName = assetName; - // break; - // } else if (systemInfo.contains("x86_64") && - // assetName.contains("macIntel")) { - // downloadUrl = asset["browser_download_url"].toString(); - // fileName = assetName; - // break; - // } - // } else if (systemInfo.contains("Windows") && assetName.contains("windows")) { - // downloadUrl = asset["browser_download_url"].toString(); - // fileName = assetName; - // break; - // } - // } - - // if (downloadUrl.isEmpty()) { - // QMessageBox::warning(mainWindow, obs_module_text("Update.DownloadFailed"), - // obs_module_text("Update.DownloadFailed.NoPackage")); - // return; - // } - - // updateManager->downloadUpdate(downloadUrl, fileName); } }); - QObject::connect(updateManager, &OneSevenLiveUpdateManager::updateNotAvailable, this, - [this]() { obs_log(LOG_INFO, "Update check: no new version available."); }); + QObject::connect(updateManager, &OneSevenLiveUpdateManager::updateNotAvailable, this, [self]() { + if (self) + obs_log(LOG_INFO, "Update check: no new version available."); + }); QObject::connect(updateManager, &OneSevenLiveUpdateManager::updateCheckFailed, this, - [this](const QString& error) { - obs_log(LOG_WARNING, "Update check failed: %s", - error.toUtf8().constData()); + [self](const QString& error) { + if (self) + obs_log(LOG_WARNING, "Update check failed: %s", + error.toUtf8().constData()); }); // Load meta data @@ -232,9 +318,7 @@ bool OneSevenLiveCoreManager::initialize() { return false; } - // Check for updates - std::thread updateThread([this]() { updateManager->checkForUpdates(); }); - updateThread.detach(); + QTimer::singleShot(0, updateManager, &OneSevenLiveUpdateManager::checkForUpdates); isStartupRestore = true; @@ -257,6 +341,66 @@ void OneSevenLiveCoreManager::handleCheckUpdateClicked() { } } +void OneSevenLiveCoreManager::handleDiagnosticsClicked() { + // Create and show the diagnostics dialog + seventeen::diag::ui::DiagnosticsDialog dialog(mainWindow); + dialog.exec(); +} + +void OneSevenLiveCoreManager::handleWebsocketMessage(const std::string& clientId, + const std::string& message) { + WsMessage m; + if (!WsMessage::parse(message, m)) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] JSON parse error in message from %s", + clientId.c_str()); + return; + } + // output m for debug + // obs_log(LOG_INFO, "[17Live WebSocket Server] Message from %s: %s", clientId.c_str(), + // m.dump().c_str()); + const bool hasServer = (this->websocketServer_ && this->websocketServer_->is_running()); + if (m.type.empty() || !hasServer) { + return; + } + if (m.is(ws::TypeAction)) { + const std::string actionType = m.payloadString("type"); + if (actionType == ws::ActionRegisterChatDock) { + chatDockClientId = clientId; + obs_log(LOG_INFO, "[ChatQueue] ChatDock registered client=%s", clientId.c_str()); + flushChatEventQueue(); + return; + } + } else if (m.is(ws::EventAblyChatMessage)) { + const std::string roomID = m.payloadString("roomID"); + const std::string data = m.payloadString("data"); + if (roomID.empty() || data.empty()) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Missing roomID or data in Ably message"); + return; + } + // Process Ably chat message via unified handler + { + nlohmann::json wrapper; + wrapper["messages"] = nlohmann::json::array({nlohmann::json{{"data", data}}}); + OneSevenLiveChatMessageHandler handler; + handler.handleRaw(wrapper.dump()); + } + } +} + +void OneSevenLiveCoreManager::handleWebsocketConnectionChanged(const std::string& clientId, + bool connected) { + if (connected) { + obs_log(LOG_INFO, "[17Live WebSocket] Client %s connected", clientId.c_str()); + flushChatEventQueue(); + } else { + obs_log(LOG_INFO, "[17Live WebSocket] Client %s disconnected", clientId.c_str()); + if (!chatDockClientId.empty() && chatDockClientId == clientId) { + chatDockClientId.clear(); + } + } +} + void OneSevenLiveCoreManager::load17LiveConfig(const OneSevenLiveLoginData& loginData) { std::string region = loginData.userInfo.region.toStdString(); if (region.empty()) { @@ -277,20 +421,66 @@ void OneSevenLiveCoreManager::load17LiveConfig(const OneSevenLiveLoginData& logi } void OneSevenLiveCoreManager::shutdown() { + m_cancelFlag.store(true); if (!initialized) { return; } + if (obs_frontend_streaming_active()) { + if (streamManager) { + streamManager->stopOBSStreaming(); + } else { + obs_frontend_streaming_stop(); + // Don't sleep on main thread + // std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } + QElapsedTimer t; + t.start(); + while (obs_frontend_streaming_active() && t.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + // Save dock state before closing any docks saveDockState(); closeAllDocks(); + // After docks are closed and UI timers stopped, shutdown MultiRTMP manager + { + auto* multiMgr = OneSevenLiveMultiRtmpManager::peekInstance(); + if (multiMgr) { + multiMgr->shutdown(); + OneSevenLiveMultiRtmpManager::destroyInstance(); + // Don't sleep on main thread + // std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } + } + + // Stop WebSocket server + if (websocketServer_) { + websocketServer_->stop(); + obs_log(LOG_INFO, "[17Live Core] WebSocket server stopped"); + } + + // Stop HTTP server + if (httpServer_) { + // Stop synchronous to ensure clean shutdown before destroying other resources + httpServer_->stop(); + obs_log(LOG_INFO, "[17Live Core] HTTP server stopped"); + } + // Clean up menu manager resources if (menuManager) { menuManager->cleanup(); } + if (ytChatDiscoverTimer) { + ytChatDiscoverTimer->stop(); + ytChatDiscoverTimer->deleteLater(); + ytChatDiscoverTimer = nullptr; + } + initialized = false; } @@ -310,7 +500,420 @@ OneSevenLiveConfigManager* OneSevenLiveCoreManager::getConfigManager() const { return configManager.get(); } +OneSevenLiveStreamManager* OneSevenLiveCoreManager::getStreamManager() const { + return streamManager.get(); +} + +OneSevenLiveWebsocketServer* OneSevenLiveCoreManager::getWebsocketServer() const { + return websocketServer_.get(); +} + +OneSevenLiveHttpServer* OneSevenLiveCoreManager::getHttpServer() const { + return httpServer_.get(); +} + +OneSevenLiveTwitchAuth* OneSevenLiveCoreManager::getTwitchAuth() const { + return twitchAuth.get(); +} + +OneSevenLiveYouTubeAuth* OneSevenLiveCoreManager::getYouTubeAuth() const { + return youtubeAuth.get(); +} + +OneSevenLiveYouTubeChatClient* OneSevenLiveCoreManager::getYouTubeChatClient() const { + return youtubeChatClient.get(); +} + +OneSevenLiveYouTubeClient* OneSevenLiveCoreManager::getYouTubeApiClient() const { + return youtubeApiClient.get(); +} + +OneSevenLiveTwitchChatClient* OneSevenLiveCoreManager::getTwitchChatClient() const { + return twitchChatClient.get(); +} + +OneSevenLiveAblyChatClient* OneSevenLiveCoreManager::getAblyChatClient() const { + return ablyChatClient.get(); +} + +void OneSevenLiveCoreManager::createYouTubeChatClient() { + return; +} + +void OneSevenLiveCoreManager::createTwitchChatClient() { + if (twitchChatClient) { + return; + } + + twitchChatClient = std::make_unique(this); + + // If we have Twitch auth, try to connect automatically when needed + if (twitchAuth) { + const QString oauth = twitchAuth->getAccessToken(); + QString login; + QString displayName; + QString userId; + QString profileImageUrl; + QString email; + int viewCount = 0; + if (configManager && configManager->getTwitchUserInfo(userId, login, displayName, + profileImageUrl, email, viewCount)) { + if (!login.isEmpty() && !oauth.isEmpty()) { + twitchChatClient->connectToChat(login, oauth); + } + } + } +} + +void OneSevenLiveCoreManager::createAblyChatClient() { + if (ablyChatClient) + return; + ablyChatClient = std::make_unique(this); +} + +void OneSevenLiveCoreManager::destroyAblyChatClient() { + if (ablyChatClient) { + ablyChatClient->disconnect(); + ablyChatClient.reset(); + } +} + +void OneSevenLiveCoreManager::connectAblyChat(const QString& roomId, const QString& token) { + createAblyChatClient(); + if (!ablyChatClient) + return; + ablyChatClient->setRoomId(roomId); + if (!token.isEmpty()) + ablyChatClient->setAblyToken(token); + + QPointer self = this; + ablyChatClient->setAuthCallback([self](const QString& rid, nlohmann::json& out) { + if (!self) + return false; + auto* api = self->getApiWrapper(); + if (!api) + return false; + return api->GetAblyToken(rid.toStdString(), out); + }); + ablyChatClient->connect(); +} + +void OneSevenLiveCoreManager::disconnectAblyChat() { + if (ablyChatClient) + ablyChatClient->disconnect(); +} + +void OneSevenLiveCoreManager::refreshRockZoneUserList() { + if (rockZoneDock) { + rockZoneDock->refreshUserList(); + } +} + +void OneSevenLiveCoreManager::enqueueOrBroadcastChatEvent(const QString& type, + const nlohmann::json& payload) { + obs_log(LOG_DEBUG, "Enqueueing chat event: %s payload: %s", type.toStdString().c_str(), + payload.dump().c_str()); + + auto* ws = getWebsocketServer(); + if (ws && ws->is_running() && !chatDockClientId.empty()) { + obs_log(LOG_DEBUG, "Sending chat event to chat dock client %s", chatDockClientId.c_str()); + auto ids = ws->getConnectedClientIds(); + if (std::find(ids.begin(), ids.end(), chatDockClientId) != ids.end()) { + ws->sendMessageToClient(chatDockClientId, + WsMessage{type.toStdString(), payload}.dump()); + return; + } + } + chatEventQueue.push_back(WsMessage{type.toStdString(), payload}); + if (chatEventQueue.size() > chatQueueMaxSize) { + chatEventQueue.pop_front(); + } + obs_log(LOG_DEBUG, "[ChatQueue] Enqueued type=%s size=%zu", type.toUtf8().constData(), + chatEventQueue.size()); +} + +void OneSevenLiveCoreManager::flushChatEventQueue() { + auto* ws = getWebsocketServer(); + if (!ws || !ws->is_running()) + return; + if (chatDockClientId.empty()) + return; + auto ids = ws->getConnectedClientIds(); + if (std::find(ids.begin(), ids.end(), chatDockClientId) == ids.end()) + return; + obs_log(LOG_DEBUG, "[ChatQueue] Flushing %zu events to ChatDock client %s", + chatEventQueue.size(), chatDockClientId.c_str()); + while (!chatEventQueue.empty()) { + const auto& m = chatEventQueue.front(); + // std::string payloadStr = m.payload.dump(); + // if (payloadStr.size() > 512) { + // payloadStr = payloadStr.substr(0, 512) + "..."; + // } + // obs_log(LOG_INFO, "[ChatQueue] Flush item type=%s payload=%s", m.type.c_str(), + // payloadStr.c_str()); + ws->sendMessageToClient(chatDockClientId, m.dump()); + chatEventQueue.pop_front(); + } + obs_log(LOG_DEBUG, "[ChatQueue] Flush complete"); +} + +void OneSevenLiveCoreManager::destroyYouTubeChatClient() { + return; +} + +void OneSevenLiveCoreManager::destroyTwitchChatClient() { + if (twitchChatClient) { + twitchChatClient->leaveAllChannels(); + twitchChatClient->disconnectFromChat(); + twitchChatClient.reset(); + } +} + +void OneSevenLiveCoreManager::startYouTubeChatPolling(const QString& liveChatId) { + UNUSED_PARAMETER(liveChatId); + return; +} + +void OneSevenLiveCoreManager::stopYouTubeChatPolling() { + return; +} + +void OneSevenLiveCoreManager::orchestrateYouTubeBroadcast(const QString& title) { + UNUSED_PARAMETER(title); + return; +#if 0 + if (!youtubeApiClient) { + createYouTubeChatClient(); + } + if (!youtubeApiClient || !youtubeApiClient->hasValidAuth()) { + return; + } + youtubeApiClient->setTimeout(12000); + auto selectedStreamIdPtr = std::make_shared(); + auto connStreams = std::make_shared(); + QTimer* tStreams = new QTimer(this); + tStreams->setSingleShot(true); + tStreams->setInterval(12000); + connect(tStreams, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: getMyLiveStreams"); + }); + tStreams->start(); + *connStreams = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::myLiveStreamsReceived, this, + [this, title, connStreams, selectedStreamIdPtr, tStreams](const YouTubeLiveStreamListResponse& resp) { + QObject::disconnect(*connStreams); + if (tStreams) { tStreams->stop(); tStreams->deleteLater(); } + QString chosen; + for (const auto& s : resp.items) { + if (!s.id.isEmpty()) { + if (s.snippet.isDefaultStream || s.status.streamStatus.compare("active", Qt::CaseInsensitive) == 0) { + chosen = s.id; + break; + } + if (chosen.isEmpty()) { + chosen = s.id; + } + } + } + if (chosen.isEmpty()) { + auto connStreamCreated = std::make_shared(); + QTimer* tStreamCreate = new QTimer(this); + tStreamCreate->setSingleShot(true); + tStreamCreate->setInterval(12000); + connect(tStreamCreate, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: createLiveStream"); + }); + *connStreamCreated = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveStreamCreated, this, + [this, connStreamCreated, selectedStreamIdPtr, tStreamCreate, title](const YouTubeLiveStream& stream) { + QObject::disconnect(*connStreamCreated); + if (tStreamCreate) { tStreamCreate->stop(); tStreamCreate->deleteLater(); } + if (!stream.id.isEmpty()) { + *selectedStreamIdPtr = stream.id; + auto connCreated = std::make_shared(); + QTimer* tCreate = new QTimer(this); + tCreate->setSingleShot(true); + tCreate->setInterval(12000); + connect(tCreate, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: createLiveBroadcast"); + }); + tCreate->start(); + *connCreated = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastCreated, this, + [this, connCreated, selectedStreamIdPtr, tCreate](const QString& bid) { + QObject::disconnect(*connCreated); + if (tCreate) { tCreate->stop(); tCreate->deleteLater(); } + if (!bid.isEmpty() && !selectedStreamIdPtr->isEmpty()) { + auto connBoundLocal = std::make_shared(); + QTimer* tBindLocal = new QTimer(this); + tBindLocal->setSingleShot(true); + tBindLocal->setInterval(12000); + connect(tBindLocal, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: bindLiveBroadcast"); + }); + *connBoundLocal = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastBound, this, + [this, connBoundLocal, tBindLocal](const QString& bid) { + QObject::disconnect(*connBoundLocal); + if (tBindLocal) { tBindLocal->stop(); tBindLocal->deleteLater(); } + auto connPre = std::make_shared(); + QTimer* tPre = new QTimer(this); + tPre->setSingleShot(true); + tPre->setInterval(12000); + connect(tPre, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: getLiveBroadcastById"); + }); + tPre->start(); + *connPre = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastReceived, this, + [this, connPre, tPre](const YouTubeLiveBroadcast& b) { + QObject::disconnect(*connPre); + if (tPre) { tPre->stop(); tPre->deleteLater(); } + QString s = b.status.lifeCycleStatus; + if (!s.isEmpty() && s.compare("complete", Qt::CaseInsensitive) != 0) { + auto connTransitionedLocal = std::make_shared(); + QTimer* tTransLocal = new QTimer(this); + tTransLocal->setSingleShot(true); + tTransLocal->setInterval(12000); + connect(tTransLocal, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: transitionLiveBroadcast"); + }); + *connTransitionedLocal = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastTransitioned, this, + [this, connTransitionedLocal, tTransLocal]() { + QObject::disconnect(*connTransitionedLocal); + if (tTransLocal) { tTransLocal->stop(); tTransLocal->deleteLater(); } + if (youtubeChatClient) { + youtubeChatClient->startDiscovery(); + } + }); + tTransLocal->start(); + youtubeApiClient->transitionLiveBroadcast(b.id, QString("live")); + } else { + obs_log(LOG_WARNING, "YouTube broadcast not transitionable: id=%s status=%s", + b.id.toUtf8().constData(), s.toUtf8().constData()); + } + }); + youtubeApiClient->getLiveBroadcastById(bid); + }); + tBindLocal->start(); + youtubeApiClient->bindLiveBroadcast(bid, *selectedStreamIdPtr); + } + }); + youtubeApiClient->createLiveBroadcast(title); + } + }); + tStreamCreate->start(); + youtubeApiClient->createLiveStream(title); + return; + } + *selectedStreamIdPtr = chosen; + auto connCreated = std::make_shared(); + QTimer* tCreate = new QTimer(this); + tCreate->setSingleShot(true); + tCreate->setInterval(12000); + connect(tCreate, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: createLiveBroadcast"); + }); + tCreate->start(); + *connCreated = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastCreated, this, + [this, connCreated, selectedStreamIdPtr, tCreate](const QString& bid) { + QObject::disconnect(*connCreated); + if (tCreate) { tCreate->stop(); tCreate->deleteLater(); } + if (!bid.isEmpty() && !selectedStreamIdPtr->isEmpty()) { + auto connBoundLocal = std::make_shared(); + QTimer* tBindLocal = new QTimer(this); + tBindLocal->setSingleShot(true); + tBindLocal->setInterval(12000); + connect(tBindLocal, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: bindLiveBroadcast"); + }); + *connBoundLocal = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastBound, this, + [this, connBoundLocal, tBindLocal](const QString& bid) { + QObject::disconnect(*connBoundLocal); + if (tBindLocal) { tBindLocal->stop(); tBindLocal->deleteLater(); } + auto connPre = std::make_shared(); + QTimer* tPre = new QTimer(this); + tPre->setSingleShot(true); + tPre->setInterval(12000); + connect(tPre, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: getLiveBroadcastById"); + }); + tPre->start(); + *connPre = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastReceived, this, + [this, connPre, tPre](const YouTubeLiveBroadcast& b) { + QObject::disconnect(*connPre); + if (tPre) { tPre->stop(); tPre->deleteLater(); } + QString s = b.status.lifeCycleStatus; + if (!s.isEmpty() && s.compare("complete", Qt::CaseInsensitive) != 0) { + auto connTransitionedLocal = std::make_shared(); + QTimer* tTransLocal = new QTimer(this); + tTransLocal->setSingleShot(true); + tTransLocal->setInterval(12000); + connect(tTransLocal, &QTimer::timeout, this, [this]() { + obs_log(LOG_WARNING, "YouTube orchestration timeout: transitionLiveBroadcast"); + }); + *connTransitionedLocal = connect(youtubeApiClient.get(), &OneSevenLiveYouTubeClient::liveBroadcastTransitioned, this, + [this, connTransitionedLocal, tTransLocal]() { + QObject::disconnect(*connTransitionedLocal); + if (tTransLocal) { tTransLocal->stop(); tTransLocal->deleteLater(); } + if (youtubeChatClient) { + youtubeChatClient->startDiscovery(); + } + }); + tTransLocal->start(); + youtubeApiClient->transitionLiveBroadcast(b.id, QString("live")); + } else { + obs_log(LOG_WARNING, "YouTube broadcast not transitionable: id=%s status=%s", + b.id.toUtf8().constData(), s.toUtf8().constData()); + } + }); + youtubeApiClient->getLiveBroadcastById(bid); + }); + tBindLocal->start(); + youtubeApiClient->bindLiveBroadcast(bid, *selectedStreamIdPtr); + } + }); + youtubeApiClient->createLiveBroadcast(title); + }); + youtubeApiClient->getMyLiveStreams(); +#endif +} + +void OneSevenLiveCoreManager::connectTwitchChatClient(const QString& channel) { + if (!twitchChatClient) { + createTwitchChatClient(); + } + if (!twitchChatClient) { + obs_log(LOG_ERROR, "Failed to create TwitchChatClient"); + return; + } + + // If not connected, authenticate and optionally join a channel + if (!twitchChatClient->isConnected()) { + QString login; + QString displayName; + QString userId; + QString profileImageUrl; + QString email; + int viewCount = 0; + QString oauth = twitchAuth ? twitchAuth->getAccessToken() : QString(); + if (configManager && configManager->getTwitchUserInfo(userId, login, displayName, + profileImageUrl, email, viewCount)) { + if (!login.isEmpty() && !oauth.isEmpty()) { + twitchChatClient->connectToChat(login, oauth); + } + } + } + + if (!channel.isEmpty()) { + twitchChatClient->joinChannel(channel); + } +} + +void OneSevenLiveCoreManager::disconnectTwitchChatClient() { + if (twitchChatClient) { + twitchChatClient->leaveAllChannels(); + twitchChatClient->disconnectFromChat(); + } +} + bool OneSevenLiveCoreManager::handleLoginClicked() { + m_cancelFlag.store(false); OneSevenLiveLoginDialog dialog(mainWindow, getApiWrapper()); // Connect login success signal to main window slot function @@ -328,8 +931,14 @@ void OneSevenLiveCoreManager::handleLoginSuccess(const OneSevenLiveLoginData& lo return; } - // Use the new centralized login state handler - handleLoginStateChanged(true, loginData); + QPointer self = this; + QMetaObject::invokeMethod( + this, + [self, loginData]() { + if (self) + self->handleLoginStateChanged(true, loginData); + }, + Qt::QueuedConnection); } void OneSevenLiveCoreManager::handleLoginStateChanged(bool isLoggedIn, @@ -345,6 +954,30 @@ void OneSevenLiveCoreManager::handleLoginStateChanged(bool isLoggedIn, void OneSevenLiveCoreManager::performLoginOperations(const OneSevenLiveLoginData& loginData) { obs_log(LOG_INFO, "performLoginOperations"); + loggingIn.store(true); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + + // if apiWrappers token is empty or not equal to loginData.accessToken.toStdString(), update it + if (apiWrapper->getToken().empty() || + apiWrapper->getToken() != loginData.jwtAccessToken.toStdString()) { + apiWrapper->setToken(loginData.jwtAccessToken.toStdString()); + } + + // Initialize stream manager (after apiWrapper is ready) + streamManager = + std::make_unique(apiWrapper.get(), configManager.get(), this); + if (!streamManager) { + obs_log(LOG_ERROR, "[17Live Core] Failed to create stream manager instance"); + loggingIn.store(false); + return; + } + + QPointer self = this; + QTimer::singleShot(0, this, [self]() { + if (self) + self->loadGifts(); + }); // if apiWrappers token is empty or not equal to loginData.accessToken.toStdString(), update it if (apiWrapper->getToken().empty() || @@ -361,27 +994,228 @@ void OneSevenLiveCoreManager::performLoginOperations(const OneSevenLiveLoginData } menuManager->updateLoginStatus(true, username); - // Load configuration - load17LiveConfig(loginData); + QTimer::singleShot(0, this, [self, loginData]() { + if (self) + self->load17LiveConfig(loginData); + }); // Restore dock states if this is during startup and there are saved states if (isStartupRestore) { - restoreDockStatesOnLogin(); - isStartupRestore = false; + QTimer::singleShot(0, this, [self]() { + if (self) { + self->restoreDockStatesOnLogin(); + self->isStartupRestore = false; + } + }); } + + QTimer::singleShot(0, this, [self]() { + if (!self) + return; + // self->createYouTubeChatClient(); + self->createTwitchChatClient(); + self->setConnection(); + }); + + QTimer::singleShot(0, this, [self]() { + if (!self) + return; + if (self->streamManager && self->apiWrapper) { + const qint64 rid = self->streamManager->getRoomID(); + if (rid > 0) { + self->m_cancelFlag.store(false); + self->connectAblyChat(QString::number(rid), QString()); + } + } + }); + + // discovery is managed by YouTubeChatClient + loggingIn.store(false); +} + +void OneSevenLiveCoreManager::setConnection() { + connect( + streamManager.get(), &OneSevenLiveStreamManager::streamStatusChanged, this, + [this](OneSevenLiveStreamingStatus status_) { + status = status_; + if (liveListDock) { + liveListDock->setStatus(status_); + } + + // Handle stream status change + if (status_ == OneSevenLiveStreamingStatus::Streaming) { + // Start timer to check stream status every 30 seconds + if (!streamCheckTimer) { + streamCheckTimer = new QTimer(this); + connect(streamCheckTimer, &QTimer::timeout, this, [this]() { + if (streamCheckInFlight.load()) { + return; + } + std::string liveStreamID; + if (!configManager || + !configManager->getConfigValue("LiveStreamID", liveStreamID)) { + return; + } + streamCheckInFlight.store(true); + QPointer self = this; + ScheduleOBSTask([self, liveStreamID]() { + if (!self) + return; + bool ok = false; + try { + if (self->apiWrapper) { + ok = self->apiWrapper->CheckStream(liveStreamID); + } + } catch (...) { + ok = false; + } + if (self) { + QMetaObject::invokeMethod( + self, + [self, ok]() { + if (!self) + return; + if (!ok) { + self->consecutiveFailureCount++; + obs_log( + LOG_WARNING, + "Stream check failed. Consecutive failures: %d/%d", + self->consecutiveFailureCount, + MAX_CONSECUTIVE_FAILURES); + if (self->consecutiveFailureCount >= + MAX_CONSECUTIVE_FAILURES) { + obs_log( + LOG_ERROR, + "Stream check failed %d times consecutively. " + "Showing auto-close confirmation.", + MAX_CONSECUTIVE_FAILURES); + QString message = + QString( + obs_module_text( + "Live.Settings.CloseLive.Auto.Message")) + .arg(MAX_CONSECUTIVE_FAILURES); + if (self->showAutoCloseConfirmation(message)) { + self->closeLive(true); + if (self->streamCheckTimer) { + self->streamCheckTimer->stop(); + self->streamCheckTimer->deleteLater(); + self->streamCheckTimer = nullptr; + } + } + self->consecutiveFailureCount = 0; + } + } else { + if (self->consecutiveFailureCount > 0) { + obs_log(LOG_INFO, + "Stream check succeeded. Resetting failure " + "count from %d to 0.", + self->consecutiveFailureCount); + self->consecutiveFailureCount = 0; + } + } + self->streamCheckInFlight.store(false); + }, + Qt::QueuedConnection); + } + }); + }); + } + streamCheckTimer->start(30000); // 30 seconds + + // trigger reload gifts when stream is live + loadGifts(); + } else { + if (streamCheckTimer) { + streamCheckTimer->stop(); + streamCheckTimer->deleteLater(); + streamCheckTimer = nullptr; + streamCheckInFlight.store(false); + } + if (pendingLogout.load()) { + pendingLogout.store(false); + QPointer self = this; + QMetaObject::invokeMethod( + this, + [self]() { + if (self) + self->handleLoginStateChanged(false); + }, + Qt::QueuedConnection); + } + } + }); } void OneSevenLiveCoreManager::performLogoutOperations() { obs_log(LOG_INFO, "performLogoutOperations"); + loggingOut.store(true); + m_cancelFlag.store(true); + + { + auto* ws = getWebsocketServer(); + if (ws && ws->is_running()) { + ws->closeAllClients(); + } + } + + // destroyYouTubeChatClient(); + destroyTwitchChatClient(); + destroyAblyChatClient(); + + if (streamCheckTimer) { + streamCheckTimer->stop(); + streamCheckTimer->deleteLater(); + streamCheckTimer = nullptr; + streamCheckInFlight.store(false); + } - // Close all dock windows closeAllDocks(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + + streamManager.reset(); + // Reset login status in menu menuManager->updateLoginStatus(false, ""); // Clear login data configManager->clearLoginData(); + + // Clear third-party platform authorization data + configManager->clearTwitchTokens(); + configManager->clearTwitchUserInfo(); + // configManager->clearYouTubeAccessToken(); + // configManager->clearYouTubeRefreshToken(); + + // Clear streaming configuration + configManager->clearStreamingInfo(); + configManager->clearWhipStreamingInfo(); + configManager->clearStreamingPullUrl(); + + // Clear in-memory auth states + if (twitchAuth) { + twitchAuth->clearTokens(); + } + // if (youtubeAuth) { + // youtubeAuth->clearToken(); + // youtubeAuth->stopAutoRefresh(); + // } + + if (ytChatDiscoverTimer) { + ytChatDiscoverTimer->stop(); + ytChatDiscoverTimer->deleteLater(); + ytChatDiscoverTimer = nullptr; + } + + chatDockClientId.clear(); + chatEventQueue.clear(); + if (apiWrapper) { + apiWrapper->setToken(std::string()); + } + + // Destroy chat clients on logout + loggingOut.store(false); } void OneSevenLiveCoreManager::restoreDockStatesOnLogin() { @@ -410,15 +1244,51 @@ void OneSevenLiveCoreManager::restoreDockStatesOnLogin() { handleRockZoneClicked(); } + // Restore multi-RTMP dock if it was previously shown + if (configManager->getDockVisibility("multiRtmp")) { + handleMultiRtmpClicked(); + } + + // Restore preview dock if it was previously shown + if (configManager->getDockVisibility("previewDock")) { + handlePreviewDockClicked(); + } + // Apply the saved dock layout mainWindow->restoreState(dockState); + QTimer::singleShot(0, this, [this]() { + QList docks; + QList sizes; + if (streamingDock) { + docks << streamingDock; + sizes << 600; + } + if (liveListDock) { + docks << liveListDock; + sizes << 400; + } + if (multiRtmpDock) { + docks << multiRtmpDock; + sizes << 400; + } + if (previewDock) { + docks << previewDock; + sizes << 480; + } + if (!docks.isEmpty() && mainWindow) { + mainWindow->resizeDocks(docks, sizes, Qt::Vertical); + } + }); + // Update menu visibility status after restoration if (menuManager) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), + menuManager->updateDockVisibility(chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), liveListDock && liveListDock->isVisible(), - rockZoneDock && rockZoneDock->isVisible()); + rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), + previewDock && previewDock->isVisible()); } } } @@ -431,7 +1301,7 @@ void OneSevenLiveCoreManager::closeAllDocks() { streamingVisible = streamingDock->isVisible(); streamingDock->disconnect(this); streamingDock->close(); - streamingDock->deleteLater(); + delete streamingDock; streamingDock = nullptr; } configManager->setDockVisibility("streaming", streamingVisible); @@ -441,7 +1311,7 @@ void OneSevenLiveCoreManager::closeAllDocks() { liveListVisible = liveListDock->isVisible(); liveListDock->disconnect(this); liveListDock->close(); - liveListDock->deleteLater(); + delete liveListDock; liveListDock = nullptr; } configManager->setDockVisibility("liveList", liveListVisible); @@ -451,66 +1321,100 @@ void OneSevenLiveCoreManager::closeAllDocks() { rockZoneVisible = rockZoneDock->isVisible(); rockZoneDock->disconnect(this); rockZoneDock->close(); - rockZoneDock->deleteLater(); + delete rockZoneDock; rockZoneDock = nullptr; } configManager->setDockVisibility("rockZone", rockZoneVisible); bool chatRoomVisible = false; - if (chatRoomDock) { - chatRoomVisible = chatRoomDock->isVisible(); - chatRoomDock->disconnect(this); - - obs_log(LOG_INFO, "Closing chat room dock"); - - if (cefView) { - delete cefView; - cefView = nullptr; + if (chatDock) { + chatRoomVisible = chatDock->isVisible(); + chatDock->disconnect(this); + OneSevenLiveChatWidget* widget = qobject_cast(chatDock->widget()); + if (widget) { + obs_log(LOG_INFO, "Shutting down chat widget in closeAllDocks"); + widget->shutdown(); } - - chatRoomDock->close(); - chatRoomDock->deleteLater(); - chatRoomDock = nullptr; + chatDock->close(); + delete chatDock; + chatDock = nullptr; } configManager->setDockVisibility("chatRoom", chatRoomVisible); + bool multiRtmpVisible = false; + if (multiRtmpDock) { + multiRtmpVisible = multiRtmpDock->isVisible(); + multiRtmpDock->disconnect(this); + multiRtmpDock->close(); + delete multiRtmpDock; + multiRtmpDock = nullptr; + } + configManager->setDockVisibility("multiRtmp", multiRtmpVisible); + + bool previewDockVisible = false; + if (previewDock) { + previewDockVisible = previewDock->isVisible(); + previewDock->disconnect(this); + previewDock->close(); + delete previewDock; + previewDock = nullptr; + } + configManager->setDockVisibility("previewDock", previewDockVisible); + // Update menu visibility status after closing all docks if (menuManager) { - menuManager->updateDockVisibility(false, false, false, false); + menuManager->updateDockVisibility(false, false, false, false, false, false); } } void OneSevenLiveCoreManager::handleLogoutClicked() { obs_log(LOG_INFO, "handleLogoutClicked"); - // Check if currently streaming if (status == OneSevenLiveStreamingStatus::Streaming) { - // Show warning message to user about interrupting live stream - QMessageBox msgBox; - msgBox.setWindowTitle(obs_module_text("Logout.Warning.Title")); - msgBox.setText(obs_module_text("Logout.Warning.Message")); + auto* msgBox = new QMessageBox(mainWindow); + msgBox->setWindowTitle(obs_module_text("Logout.Warning.Title")); + msgBox->setText(obs_module_text("Logout.Warning.Message")); QPushButton* confirmButton = - msgBox.addButton(obs_module_text("Logout.Warning.Button.Yes"), QMessageBox::YesRole); + msgBox->addButton(obs_module_text("Logout.Warning.Button.Yes"), QMessageBox::YesRole); QPushButton* cancelButton = - msgBox.addButton(obs_module_text("Logout.Warning.Button.No"), QMessageBox::NoRole); - msgBox.setDefaultButton(cancelButton); - - msgBox.exec(); - if (msgBox.clickedButton() != confirmButton) { - // User cancelled the operation - return; - } - - // User confirmed, stop streaming using the streaming dock's method - closeLive(false); // Pass false to indicate manual stream closure + msgBox->addButton(obs_module_text("Logout.Warning.Button.No"), QMessageBox::NoRole); + msgBox->setDefaultButton(cancelButton); + + QPointer self = this; + connect(msgBox, &QMessageBox::finished, this, [self, msgBox, confirmButton](int) { + if (msgBox->clickedButton() != confirmButton) { + msgBox->deleteLater(); + return; + } + if (self) { + QMetaObject::invokeMethod( + self, + [self]() { + if (self) { + self->pendingLogout.store(true); + self->closeLive(false); + } + }, + Qt::QueuedConnection); + } + msgBox->deleteLater(); + }); + msgBox->open(); + return; } - // Use the new centralized logout state handler - handleLoginStateChanged(false); + QPointer self = this; + QMetaObject::invokeMethod( + this, + [self]() { + if (self) + self->handleLoginStateChanged(false); + }, + Qt::QueuedConnection); } void OneSevenLiveCoreManager::closeLive(bool isAutoClose) { - if (streamingDock) { + if (streamManager) { std::string currUserID; std::string currLiveStreamID; configManager->getConfigValue("UserID", currUserID); @@ -527,7 +1431,7 @@ void OneSevenLiveCoreManager::closeLive(bool isAutoClose) { currUserID.c_str(), currLiveStreamID.c_str()); } - streamingDock->closeLive(currUserID, currLiveStreamID, isAutoClose); + streamManager->stopStream(isAutoClose); } } @@ -543,8 +1447,9 @@ void OneSevenLiveCoreManager::handleStreamingClicked() { // Update menu item checked status if (menuManager) { menuManager->updateDockVisibility( - chatRoomDock && chatRoomDock->isVisible(), streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible()); + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); } } @@ -560,20 +1465,19 @@ void OneSevenLiveCoreManager::createStreamingDock() { } // Create and show streaming window - streamingDock = - new OneSevenLiveStreamingDock(mainWindow, apiWrapper.get(), configManager.get()); + streamingDock = new OneSevenLiveStreamingDock(mainWindow, streamManager.get(), apiWrapper.get(), + configManager.get()); streamingDock->setObjectName("OneSevenLiveStreamingDock"); streamingDock->setMaximumWidth(600); streamingDock->resize(450, 600); + streamingDock->setMinimumHeight(400); streamingDock->setAllowedAreas(Qt::AllDockWidgetAreas); mainWindow->addDockWidget(Qt::RightDockWidgetArea, streamingDock); // Only restore state during startup, otherwise set floating and center if (isStartupRestore) { - // During startup restoration, the state will be restored by initialize() method - streamingDock->setVisible(true); } else { // First time creation or manual creation - set floating and center streamingDock->setFloating(true); @@ -587,8 +1491,6 @@ void OneSevenLiveCoreManager::createStreamingDock() { streamingDock->move(x, y); } - streamingDock->loadRoomInfo(loginData.userInfo.roomID); - if (streamingDockFirstLoad) { connect(streamingDock, &OneSevenLiveStreamingDock::streamInfoSaved, this, [this]() { if (liveListDock) { @@ -596,88 +1498,12 @@ void OneSevenLiveCoreManager::createStreamingDock() { } }); - connect( - streamingDock, &OneSevenLiveStreamingDock::streamStatusUpdated, this, - [this](OneSevenLiveStreamingStatus status_) { - status = status_; - if (liveListDock) { - liveListDock->setStatus(status_); - } - - // Handle stream status change - if (status_ == OneSevenLiveStreamingStatus::Streaming) { - // Start timer to check stream status every 30 seconds - if (!streamCheckTimer) { - streamCheckTimer = new QTimer(this); - connect(streamCheckTimer, &QTimer::timeout, this, [this]() { - std::string liveStreamID; - if (configManager->getConfigValue("LiveStreamID", liveStreamID)) { - if (!apiWrapper->CheckStream(liveStreamID)) { - // Stream check failed, increment consecutive failure count - consecutiveFailureCount++; - obs_log(LOG_WARNING, - "Stream check failed. Consecutive failures: %d/%d", - consecutiveFailureCount, MAX_CONSECUTIVE_FAILURES); - - // Only trigger auto-close when consecutive failures reach - // threshold - if (consecutiveFailureCount >= MAX_CONSECUTIVE_FAILURES) { - obs_log(LOG_ERROR, - "Stream check failed %d times consecutively. " - "Showing auto-close confirmation.", - MAX_CONSECUTIVE_FAILURES); - - // Show confirmation dialog before auto-closing - QString message = - QString(obs_module_text( - "Live.Settings.CloseLive.Auto.Message")) - .arg(MAX_CONSECUTIVE_FAILURES); - - if (showAutoCloseConfirmation(message)) { - obs_log(LOG_INFO, - "User confirmed auto-close live stream due to " - "stream check failures"); - closeLive( - true); // Pass true to indicate this is auto-close - if (streamCheckTimer) { - streamCheckTimer->stop(); - streamCheckTimer->deleteLater(); - streamCheckTimer = nullptr; - } - } else { - obs_log(LOG_INFO, - "User cancelled auto-close live stream"); - } - // Reset failure counter regardless of user choice - consecutiveFailureCount = 0; - } - } else { - // Stream check succeeded, reset consecutive failure counter - if (consecutiveFailureCount > 0) { - obs_log(LOG_INFO, - "Stream check succeeded. Resetting failure count " - "from %d to 0.", - consecutiveFailureCount); - consecutiveFailureCount = 0; - } - } - } - }); - } - streamCheckTimer->start(30000); // 30 seconds - } else { - // Stop timer when not streaming - if (streamCheckTimer) { - streamCheckTimer->stop(); - streamCheckTimer->deleteLater(); - streamCheckTimer = nullptr; - } - } - }); - connect(streamingDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), visible, - liveListDock && liveListDock->isVisible()); + menuManager->updateDockVisibility(chatDock && chatDock->isVisible(), visible, + liveListDock && liveListDock->isVisible(), + rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), + previewDock && previewDock->isVisible()); }); streamingDockFirstLoad = false; @@ -696,8 +1522,9 @@ void OneSevenLiveCoreManager::handleRockZoneClicked() { // Update menu item checked status if (menuManager) { menuManager->updateDockVisibility( - chatRoomDock && chatRoomDock->isVisible(), streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible()); + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); } } @@ -740,12 +1567,29 @@ void OneSevenLiveCoreManager::createRockZoneDock() { rockZoneDock->move(x, y); } + if (streamManager) { + connect(streamManager.get(), &OneSevenLiveStreamManager::streamStatusChanged, this, + [this](OneSevenLiveStreamingStatus status) { + if (status == OneSevenLiveStreamingStatus::NotStarted && rockZoneDock) { + rockZoneDock->clearUserList(); + } + }); + connect(streamManager.get(), &OneSevenLiveStreamManager::obsStreamStopped, this, + [this](int, const QString&) { + if (rockZoneDock) { + rockZoneDock->clearUserList(); + } + }); + } + if (rockZoneDockFirstLoad) { // When dock is closed, uncheck menu item status connect(rockZoneDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), + menuManager->updateDockVisibility(chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible(), visible); + liveListDock && liveListDock->isVisible(), visible, + multiRtmpDock && multiRtmpDock->isVisible(), + previewDock && previewDock->isVisible()); }); rockZoneDockFirstLoad = false; @@ -766,8 +1610,6 @@ void OneSevenLiveCoreManager::handleLiveListClicked() { // Only restore state during startup, otherwise set floating and center if (isStartupRestore) { - // During startup restoration, the state will be restored by initialize() method - liveListDock->setVisible(true); } else { // First time creation or manual creation - set floating and center liveListDock->setFloating(true); @@ -848,8 +1690,11 @@ void OneSevenLiveCoreManager::handleLiveListClicked() { // When dock is closed, uncheck menu item status connect(liveListDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), - streamingDock && streamingDock->isVisible(), visible); + menuManager->updateDockVisibility(chatDock && chatDock->isVisible(), + streamingDock && streamingDock->isVisible(), visible, + rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), + previewDock && previewDock->isVisible()); }); } else { liveListDock->setVisible(!liveListDock->isVisible()); @@ -857,9 +1702,10 @@ void OneSevenLiveCoreManager::handleLiveListClicked() { // Update menu item checked status if (menuManager) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), - streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible()); + menuManager->updateDockVisibility( + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); } } @@ -890,39 +1736,6 @@ void OneSevenLiveCoreManager::saveDockState() { void OneSevenLiveCoreManager::handleChatRoomClicked() { obs_log(LOG_INFO, "handleChatRoomClicked"); - if (!chatRoomDock) { - chatRoomDock = new QDockWidget(obs_module_text("ChatRoom.Title"), mainWindow); - chatRoomDock->setObjectName("OneSevenLiveChatRoomDock"); - chatRoomDock->setAllowedAreas(Qt::AllDockWidgetAreas); - chatRoomDock->setFeatures(QDockWidget::DockWidgetMovable | - QDockWidget::DockWidgetFloatable | - QDockWidget::DockWidgetClosable); - - mainWindow->addDockWidget(Qt::RightDockWidgetArea, chatRoomDock); - - connect(chatRoomDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { - menuManager->updateDockVisibility(chatRoomDock && chatRoomDock->isVisible(), - streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible()); - }); - } else if (chatRoomDock->isVisible()) { - // cefView will be destroyed when dock is hidden - if (cefView) { - cefView->deleteLater(); - cefView = nullptr; - } - chatRoomDock->hide(); - return; - } - - if (cefView) { - cefView->deleteLater(); - cefView = nullptr; - } - - cefView = new QCefView(chatRoomDock); - chatRoomDock->setWidget(cefView); - OneSevenLiveLoginData loginData; if (!configManager->getLoginData(loginData)) { obs_log(LOG_ERROR, "Failed to get login data"); @@ -930,71 +1743,185 @@ void OneSevenLiveCoreManager::handleChatRoomClicked() { } std::string locale = GetCurrentLocale(); - + QString wsUrl = QString::fromStdString("ws://127.0.0.1:%1").arg(websocketServer_->getPort()); QString chatUrl = - QString("http://localhost:%1/%2.html?roomID=%3&userID=%4") + QString("http://localhost:%1/%2.html?roomID=%3&userID=%4&ws=%5") .arg(QString::number(httpServer_->getPort()), QString::fromStdString(locale), - QString::number(loginData.userInfo.roomID), loginData.userInfo.userID); - obs_log(LOG_INFO, "chatUrl: %s", chatUrl.toStdString().c_str()); + QString::number(loginData.userInfo.roomID), loginData.userInfo.userID, wsUrl); - chatRoomDock->resize(378, 600); - cefView->loadUrl(chatUrl); + obs_log(LOG_INFO, "Chat URL: %s", chatUrl.toStdString().c_str()); - // Only restore state during startup, otherwise set floating and center - if (isStartupRestore) { - // During startup restoration, the state will be restored by initialize() method - chatRoomDock->setVisible(true); - } else { - // First time creation or manual creation - set floating and center - chatRoomDock->setFloating(true); - chatRoomDock->setVisible(true); + if (!chatDock) { + obs_log(LOG_INFO, "Creating new chatDock instance"); + chatDock = new QDockWidget(obs_module_text("ChatRoom.Title"), mainWindow); + chatDock->setObjectName("OneSevenLiveChatDock"); + chatDock->setAllowedAreas(Qt::AllDockWidgetAreas); + chatDock->setAttribute(Qt::WA_DeleteOnClose, false); + chatDock->installEventFilter(this); + chatDock->setMinimumSize(300, 400); - // Center the dock on the main window - QRect mainWindowGeometry = mainWindow->geometry(); - int x = mainWindowGeometry.x() + (mainWindowGeometry.width() - chatRoomDock->width()) / 2; - int y = mainWindowGeometry.y() + (mainWindowGeometry.height() - chatRoomDock->height()) / 2; - chatRoomDock->move(x, y); + // Create the chat widget and set it as the dock's widget + OneSevenLiveChatWidget* chatWidget = new OneSevenLiveChatWidget(chatDock, chatUrl); + chatDock->setWidget(chatWidget); + + mainWindow->addDockWidget(Qt::RightDockWidgetArea, chatDock); + + if (isStartupRestore) { + chatDock->setVisible(true); + } else { + obs_log(LOG_INFO, "Setting chatDock to floating mode"); + chatDock->setFloating(true); + bool hadChatStored = + configManager ? configManager->getDockVisibility("chatRoom") : false; + if (!hadChatStored) { + chatDock->resize(400, 600); + } + chatDock->setVisible(true); + + // Center the dock + QRect mainWindowGeometry = mainWindow->geometry(); + int x = mainWindowGeometry.x() + (mainWindowGeometry.width() - chatDock->width()) / 2; + int y = mainWindowGeometry.y() + (mainWindowGeometry.height() - chatDock->height()) / 2; + chatDock->move(x, y); + } + + connect(chatDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { + obs_log(LOG_INFO, "chatDock visibility changed: %s, isFloating: %s", + visible ? "true" : "false", + (chatDock && chatDock->isFloating()) ? "true" : "false"); + + if (menuManager) { + menuManager->updateDockVisibility(visible, + streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), + rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), + previewDock && previewDock->isVisible()); + } + chatDockVisible = visible; + if (visible) + flushChatEventQueue(); + }); + } else { + obs_log(LOG_INFO, "Toggling existing chatDock visibility. Current: %s", + chatDock->isVisible() ? "visible" : "hidden"); + chatDock->setVisible(!chatDock->isVisible()); + if (chatDock->isVisible()) { + chatDock->raise(); + chatDock->activateWindow(); + } } - // Update chat room visibility status (considered visible when CEF view is open) + // Update visibility status for menu if (menuManager) { - menuManager->updateDockVisibility(true, streamingDock && streamingDock->isVisible(), - liveListDock && liveListDock->isVisible()); + menuManager->updateDockVisibility( + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); } } +bool OneSevenLiveCoreManager::eventFilter(QObject* obj, QEvent* event) { + if (obj == chatDock) { + if (event->type() == QEvent::Close) { + event->ignore(); + chatDock->hide(); + return true; + } + } + return QObject::eventFilter(obj, event); +} + void OneSevenLiveCoreManager::loadGifts() { + if (giftsLoading_.load()) { + obs_log(LOG_INFO, "Gifts are already loading, skipping request"); + return; + } + giftsLoading_.store(true); + obs_log(LOG_INFO, "Starting to load gifts asynchronously"); - // Run gift loading in a separate thread to avoid blocking main thread - std::thread giftLoadThread([this]() { + QPointer self = this; + ScheduleOBSTask([self]() { + if (!self) + return; + Json apiResult; + bool ok = false; try { std::string language = GetCurrentLanguage(); + if (self->apiWrapper) { + ok = self->apiWrapper->GetGifts(language, apiResult); + } + } catch (...) { + ok = false; + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, ok, apiResult]() { + if (!self) + return; + self->giftsLoading_.store(false); + if (!ok) { + obs_log(LOG_WARNING, "Failed to load gifts from API"); + return; + } + if (self->configManager) { + self->configManager->saveGifts(apiResult); + } + self->buildGiftsMapFromJson(apiResult); + }, + Qt::QueuedConnection); + } + }); +} - Json apiResult; - bool success = apiWrapper->GetGifts(language, apiResult); +void OneSevenLiveCoreManager::loadGiftsFromConfig() { + if (!configManager) + return; + nlohmann::json gifts; + if (configManager->loadGifts(gifts)) { + buildGiftsMapFromJson(gifts); + obs_log(LOG_INFO, "Loaded gifts into memory map from config"); + } +} - if (success) { - configManager->saveGifts(apiResult); - obs_log(LOG_INFO, "Gifts loaded and saved successfully"); +void OneSevenLiveCoreManager::buildGiftsMapFromJson(const nlohmann::json& giftsJson) { + obs_log(LOG_INFO, "Building gifts map from json"); - // Reload chat room dock to support new gifts - if (chatRoomDock && chatRoomDock->isVisible() && cefView) { - obs_log(LOG_INFO, "Reloading chat room to support new gifts"); - cefView->reload(); - obs_log(LOG_INFO, "Chat room reloaded with new gifts support"); + giftsMap.clear(); + try { + if (giftsJson.contains("gifts") && giftsJson["gifts"].is_array()) { + for (const auto& gift : giftsJson["gifts"]) { + if (gift.contains("giftID") && gift["giftID"].is_string()) { + const std::string gid = gift["giftID"].get(); + giftsMap[gid] = gift; } - } else { - obs_log(LOG_WARNING, "Failed to load gifts from API"); } - } catch (const std::exception& e) { - obs_log(LOG_ERROR, "Exception while loading gifts: %s", e.what()); - } catch (...) { - obs_log(LOG_ERROR, "Unknown exception while loading gifts"); } - }); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "Failed to build gifts map: %s", e.what()); + } + + obs_log(LOG_INFO, "Gifts map built with %d entries", giftsMap.size()); + emit giftsLoaded(); +} + +bool OneSevenLiveCoreManager::isGiftsLoaded() const { + return !giftsMap.empty(); +} + +bool OneSevenLiveCoreManager::isGiftsLoading() const { + return giftsLoading_.load(); +} - giftLoadThread.detach(); +std::optional OneSevenLiveCoreManager::getGiftByID( + const std::string& giftID) const { + auto it = giftsMap.find(giftID); + if (it != giftsMap.end()) + return it->second; + return std::nullopt; } bool OneSevenLiveCoreManager::showAutoCloseConfirmation(const QString& message) { @@ -1037,3 +1964,149 @@ bool OneSevenLiveCoreManager::showAutoCloseConfirmation(const QString& message) msgBox.exec(); return msgBox.clickedButton() == confirmButton; } + +void OneSevenLiveCoreManager::handleMultiRtmpClicked() { + obs_log(LOG_INFO, "handleMultiRtmpClicked"); + + if (!multiRtmpDock) { + createMultiRtmpDock(); + } else { + multiRtmpDock->setVisible(!multiRtmpDock->isVisible()); + } + + // Update menu item checked status + if (menuManager) { + menuManager->updateDockVisibility( + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); + } +} + +void OneSevenLiveCoreManager::createMultiRtmpDock() { + if (multiRtmpDock) { + return; + } + + OneSevenLiveLoginData loginData; + if (!configManager->getLoginData(loginData)) { + obs_log(LOG_ERROR, "Failed to get login data"); + return; + } + + // Create multi-RTMP dock + multiRtmpDock = new OneSevenLiveMultiRtmpDock(mainWindow); + multiRtmpDock->setObjectName("OneSevenLiveMultiRtmpDock"); + + multiRtmpDock->setMaximumWidth(600); + multiRtmpDock->resize(450, 600); + + multiRtmpDock->setAllowedAreas(Qt::AllDockWidgetAreas); + mainWindow->addDockWidget(Qt::RightDockWidgetArea, multiRtmpDock); + + // Only restore state during startup, otherwise set floating and center + if (isStartupRestore) { + } else { + // First time creation or manual creation - set floating and center + multiRtmpDock->setFloating(true); + multiRtmpDock->setVisible(true); + + // Center the dock on the main window + QRect mainWindowGeometry = mainWindow->geometry(); + int x = mainWindowGeometry.x() + (mainWindowGeometry.width() - multiRtmpDock->width()) / 2; + int y = + mainWindowGeometry.y() + (mainWindowGeometry.height() - multiRtmpDock->height()) / 2; + multiRtmpDock->move(x, y); + } + + if (multiRtmpDockFirstLoad) { + // Connect visibility change signal to update menu status + connect(multiRtmpDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { + if (menuManager) { + menuManager->updateDockVisibility(chatDock && chatDock->isVisible(), + streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), + rockZoneDock && rockZoneDock->isVisible(), + visible, previewDock && previewDock->isVisible()); + } + }); + + multiRtmpDockFirstLoad = false; + } +} + +void OneSevenLiveCoreManager::handlePreviewDockClicked() { + obs_log(LOG_INFO, "handlePreviewDockClicked"); + + if (!previewDock) { + createPreviewDock(); + } else { + previewDock->setVisible(!previewDock->isVisible()); + } + + // Update menu item checked status + if (menuManager) { + menuManager->updateDockVisibility( + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), previewDock && previewDock->isVisible()); + } +} + +void OneSevenLiveCoreManager::createPreviewDock() { + if (previewDock) { + return; + } + + QString wsUrl = QString::fromStdString("ws://127.0.0.1:%1").arg(websocketServer_->getPort()); + + QString cartoonUrl = QString("http://localhost:%1/vff/?ws=%2") + .arg(QString::number(httpServer_->getPort()), wsUrl); + obs_log(LOG_INFO, "cartoonUrl: %s", cartoonUrl.toStdString().c_str()); + // Create preview dock + previewDock = new OneSevenLivePreviewDock(mainWindow, cartoonUrl); + previewDock->setObjectName("OneSevenLivePreviewDock"); + + previewDock->setMaximumWidth(800); + previewDock->resize(640, 480); + + previewDock->setAllowedAreas(Qt::AllDockWidgetAreas); + mainWindow->addDockWidget(Qt::RightDockWidgetArea, previewDock); + + // Only restore state during startup, otherwise set floating and center + if (isStartupRestore) { + } else { + // First time creation or manual creation - set floating and center + previewDock->setFloating(true); + previewDock->setVisible(true); + + // Center the dock on the main window + QRect mainWindowGeometry = mainWindow->geometry(); + int x = mainWindowGeometry.x() + (mainWindowGeometry.width() - previewDock->width()) / 2; + int y = mainWindowGeometry.y() + (mainWindowGeometry.height() - previewDock->height()) / 2; + previewDock->move(x, y); + } + + if (previewDockFirstLoad) { + // Connect visibility change signal to update menu status + connect(previewDock, &QDockWidget::visibilityChanged, this, [this](bool visible) { + if (menuManager) { + menuManager->updateDockVisibility( + chatDock && chatDock->isVisible(), streamingDock && streamingDock->isVisible(), + liveListDock && liveListDock->isVisible(), + rockZoneDock && rockZoneDock->isVisible(), + multiRtmpDock && multiRtmpDock->isVisible(), visible); + } + }); + + previewDockFirstLoad = false; + } +} + +void OneSevenLiveCoreManager::setShuttingDown(bool v) { + shuttingDown = v; +} + +bool OneSevenLiveCoreManager::isShuttingDown() const { + return shuttingDown; +} diff --git a/src/17live/OneSevenLiveCoreManager.hpp b/src/17live/OneSevenLiveCoreManager.hpp index 5c7f568..89fb652 100644 --- a/src/17live/OneSevenLiveCoreManager.hpp +++ b/src/17live/OneSevenLiveCoreManager.hpp @@ -2,13 +2,23 @@ #include #include +#include +#include #include #include #include +#include +#include #include +#include #include "api/OneSevenLiveModels.hpp" #include "utility/NetworkDiagnostics.hpp" +#include "websocket/WsMessage.hpp" + +// Forward declarations for auth handlers +class OneSevenLiveTwitchAuth; +class OneSevenLiveYouTubeAuth; // Forward declarations class QMainWindow; @@ -25,15 +35,31 @@ class OneSevenLiveApiWrappers; class OneSevenLiveConfigManager; +#include + class OneSevenLiveStreamingDock; class OneSevenLiveStreamListDock; class OneSevenLiveRockZoneDock; +class OneSevenLiveChatWidget; + +class OneSevenLiveMultiRtmpDock; + +class OneSevenLivePreviewDock; + class OneSevenLiveHttpServer; -class QCefView; +class OneSevenLiveWebsocketServer; + +class OneSevenLiveStreamManager; + +// Forward declarations for chat clients +class OneSevenLiveYouTubeChatClient; +class OneSevenLiveTwitchChatClient; +class OneSevenLiveYouTubeClient; +class OneSevenLiveAblyChatClient; /** * @brief OneSevenLiveCoreManager class is the core management class for the 17live plugin @@ -54,6 +80,11 @@ class OneSevenLiveCoreManager : public QObject { */ static OneSevenLiveCoreManager& getInstance(QMainWindow* mainWindow = nullptr); + /** + * @brief Destroy the singleton instance + */ + static void destroyInstance(); + /** * @brief Initialize the core manager * @@ -89,12 +120,90 @@ class OneSevenLiveCoreManager : public QObject { OneSevenLiveConfigManager* getConfigManager() const; + /** + * @brief Get stream manager + * + * @return OneSevenLiveStreamManager* Pointer to stream manager + */ + OneSevenLiveStreamManager* getStreamManager() const; + + /** + * @brief Get WebSocket server + * + * @return OneSevenLiveWebsocketServer* Pointer to WebSocket server + */ + OneSevenLiveWebsocketServer* getWebsocketServer() const; + + /** + * @brief Get HTTP server + * + * @return OneSevenLiveHttpServer* Pointer to HTTP server + */ + OneSevenLiveHttpServer* getHttpServer() const; + + // Auth handlers accessors + OneSevenLiveTwitchAuth* getTwitchAuth() const; + OneSevenLiveYouTubeAuth* getYouTubeAuth() const; + + // Chat clients accessors + OneSevenLiveYouTubeChatClient* getYouTubeChatClient() const; + OneSevenLiveTwitchChatClient* getTwitchChatClient() const; + OneSevenLiveAblyChatClient* getAblyChatClient() const; + OneSevenLiveYouTubeClient* getYouTubeApiClient() const; + + // Chat clients lifecycle + void createYouTubeChatClient(); + void createTwitchChatClient(); + void destroyYouTubeChatClient(); + void destroyTwitchChatClient(); + void createAblyChatClient(); + void destroyAblyChatClient(); + void connectAblyChat(const QString& roomId, const QString& token = QString()); + void disconnectAblyChat(); + void refreshRockZoneUserList(); + std::optional getGiftByID(const std::string& giftID) const; + void enqueueOrBroadcastChatEvent(const QString& type, const nlohmann::json& payload); + + void setConnection(); + + // Chat tracking external calls + void startYouTubeChatPolling(const QString& liveChatId); + void stopYouTubeChatPolling(); + void connectTwitchChatClient(const QString& channel = QString()); + void disconnectTwitchChatClient(); + void orchestrateYouTubeBroadcast(const QString& title); + bool handleLoginClicked(); + void setShuttingDown(bool v); + bool isShuttingDown() const; + + bool isGiftsLoaded() const; + bool isGiftsLoading() const; + + signals: + void giftsLoaded(); + + public: // Disable copy constructor and assignment operator OneSevenLiveCoreManager(const OneSevenLiveCoreManager&) = delete; OneSevenLiveCoreManager& operator=(const OneSevenLiveCoreManager&) = delete; + // Accessor for cancellation flag + std::atomic* getCancelFlag() { + return &m_cancelFlag; + } + + private: + std::atomic giftsLoading_{false}; + std::atomic m_cancelFlag{false}; + std::atomic loggingOut{false}; + std::atomic loggingIn{false}; + std::atomic pendingLogout{false}; + + protected: + bool eventFilter(QObject* obj, QEvent* event) override; + private: // Private constructor, ensure instance can only be obtained through getInstance method explicit OneSevenLiveCoreManager(QMainWindow* mainWindow); @@ -109,26 +218,31 @@ class OneSevenLiveCoreManager : public QObject { static std::once_flag instanceOnceFlag; // OBS main window - QMainWindow* mainWindow; + QMainWindow* mainWindow = nullptr; // Configuration storage - std::map configMap; // Initialization flag - bool initialized; + bool initialized = false; + bool shuttingDown = false; // Flag to track if we are in startup dock restoration phase bool isStartupRestore = false; std::unique_ptr configManager; + std::unique_ptr apiWrapper; + + // Stream manager + std::unique_ptr streamManager; + // Menu manager std::unique_ptr menuManager; - std::unique_ptr apiWrapper; - std::unique_ptr httpServer_; + std::shared_ptr websocketServer_; + /** * @brief Slot function to handle successful login * @@ -155,9 +269,7 @@ class OneSevenLiveCoreManager : public QObject { void handleStreamingClicked(); void createStreamingDock(); - bool chatRoomDockFirstLoad = true; - QPointer chatRoomDock; - QPointer cefView; + QPointer chatDock; void handleChatRoomClicked(); bool liveListDockFirstLoad = true; @@ -169,6 +281,16 @@ class OneSevenLiveCoreManager : public QObject { void handleRockZoneClicked(); void createRockZoneDock(); + bool multiRtmpDockFirstLoad = true; + QPointer multiRtmpDock; + void handleMultiRtmpClicked(); + void createMultiRtmpDock(); + + bool previewDockFirstLoad = true; + QPointer previewDock; + void handlePreviewDockClicked(); + void createPreviewDock(); + void saveDockState(); void load17LiveConfig(const OneSevenLiveLoginData& loginData); @@ -181,6 +303,9 @@ class OneSevenLiveCoreManager : public QObject { // Timer for checking stream status QPointer streamCheckTimer; + std::atomic streamCheckInFlight{false}; + // Timer for periodic YouTube chat discovery + QPointer ytChatDiscoverTimer; // Consecutive failure detection related variables int consecutiveFailureCount{0}; // Consecutive failure counter @@ -189,7 +314,36 @@ class OneSevenLiveCoreManager : public QObject { void handleCheckUpdateClicked(); void checkForUpdates(); + // Diagnostics related methods + void handleDiagnosticsClicked(); + void loadGifts(); + void loadGiftsFromConfig(); + void buildGiftsMapFromJson(const nlohmann::json& giftsJson); class OneSevenLiveUpdateManager* updateManager = nullptr; + + // Auth handlers + std::unique_ptr twitchAuth; + std::unique_ptr youtubeAuth; + + // Chat clients + std::unique_ptr youtubeChatClient; + std::unique_ptr twitchChatClient; + std::unique_ptr youtubeApiClient; + std::unique_ptr ablyChatClient; + + void handleWebsocketMessage(const std::string& clientId, const std::string& message); + void handleWebsocketConnectionChanged(const std::string& clientId, bool connected); + + // Gifts lookup map: giftID (string) -> gift json + std::unordered_map giftsMap; + + bool chatDockVisible{false}; + std::deque chatEventQueue; + size_t chatQueueMaxSize{5000}; + std::string chatDockClientId; + + void flushChatEventQueue(); + bool isChatDockClientConnected() const; }; diff --git a/src/17live/OneSevenLiveCustomEventDialog.cpp b/src/17live/OneSevenLiveCustomEventDialog.cpp index 4f69e54..f7ee758 100644 --- a/src/17live/OneSevenLiveCustomEventDialog.cpp +++ b/src/17live/OneSevenLiveCustomEventDialog.cpp @@ -6,9 +6,12 @@ #include // Qt includes +#include #include +#include #include #include +#include #include #include #include @@ -24,41 +27,41 @@ #include #include #include +#include +#include +#include +#include #include #include #include -#include -#include -#include #include -#include -#include -#include -#include // Project includes #include "OneSevenLiveConfigManager.hpp" #include "api/OneSevenLiveApiWrappers.hpp" #include "utility/Common.hpp" -#include "utility/RemoteTextThread.hpp" #include "utility/CustomCalendarWidget.hpp" +#include "utility/RemoteTextThread.hpp" -// Static helper: insert zero-width spaces into CJK or other no-space text to enable line breaks with WrapAnywhere +// Static helper: insert zero-width spaces into CJK or other no-space text to enable line breaks +// with WrapAnywhere static QString insertZeroWidthSpaces(const QString& s) { QString out; out.reserve(s.size() * 2); for (int i = 0; i < s.size(); ++i) { const QChar ch = s.at(i); out.append(ch); - // Avoid inserting zero-width spaces after whitespace, and do not insert after the last character + // Avoid inserting zero-width spaces after whitespace, and do not insert after the last + // character if (i < s.size() - 1 && !ch.isSpace()) { - out.append(QChar(0x200B)); // ZERO WIDTH SPACE + out.append(QChar(0x200B)); // ZERO WIDTH SPACE } } return out; } -// Static helper: limit text to at most two lines (single wrap); overflow is elided at the end of the second line +// Static helper: limit text to at most two lines (single wrap); overflow is elided at the end of +// the second line static QString elideTextToTwoLines(const QString& text, const QFont& font, int widthPx) { if (text.isEmpty() || widthPx <= 0) return text; @@ -70,8 +73,8 @@ static QString elideTextToTwoLines(const QString& text, const QFont& font, int w layout.setTextOption(opt); layout.beginLayout(); - int firstEnd = 0; // End index of the first line (length from start to end) - int secondStart = 0; // Start index of the second line + int firstEnd = 0; // End index of the first line (length from start to end) + int secondStart = 0; // Start index of the second line int processedChars = 0; int linesCount = 0; qreal y = 0.0; @@ -131,7 +134,6 @@ OneSevenLiveCustomEventDialog::OneSevenLiveCustomEventDialog( setWindowFlags(Qt::Dialog | Qt::WindowTitleHint | Qt::WindowCloseButtonHint); fetchCustomEventAsync(); - loadGiftTabsAsync(); // Ensure all widgets are properly initialized update(); @@ -240,7 +242,7 @@ void OneSevenLiveCustomEventDialog::setupEventDateSection() { calendar->setGridVisible(true); calendar->setSelectedDate(today); dateEdit->setCalendarWidget(calendar); - + // Create form layout for date section QFormLayout* dateFormLayout = new QFormLayout(); dateFormLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); @@ -608,16 +610,19 @@ void OneSevenLiveCustomEventDialog::fetchCustomEventAsync() { bool ok = false; if (apiWrapper) { ok = apiWrapper->GetCustomEvent(userID, customEvent); + if (!ok) { + obs_log(LOG_ERROR, "Failed to get custom event: %s", + apiWrapper->getLastErrorMessage().toUtf8().constData()); + } } QMetaObject::invokeMethod( this, [this, ok, thread]() { // Update UI on main thread - if (!ok) { - obs_log(LOG_ERROR, "Failed to get custom event"); - } else { - obs_log(LOG_INFO, "id=%s, customEvent.status = %d", + // update UI whatever the result is + { + obs_log(LOG_INFO, "customEvent.eventID=%s, customEvent.status = %d", customEvent.eventID.toStdString().c_str(), customEvent.status); // Populate fields @@ -1002,7 +1007,8 @@ void OneSevenLiveCustomEventDialog::populateGiftTab(const OneSevenLiveGiftTab& g // Clear existing gift buttons for this tab QLayoutItem* item; while ((item = giftsLayout->takeAt(0)) != nullptr) { - delete item->widget(); + if (item->widget()) + item->widget()->deleteLater(); delete item; } @@ -1071,7 +1077,9 @@ void OneSevenLiveCustomEventDialog::populateGiftTab(const OneSevenLiveGiftTab& g nameEdit->setFrameStyle(QFrame::NoFrame); nameEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); nameEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - nameEdit->setStyleSheet("color: white; font-size: 12px; background: transparent; padding: 2px 0 0 0; margin: 0; border: none;"); + nameEdit->setStyleSheet( + "color: white; font-size: 12px; background: transparent; padding: 2px 0 0 0; margin: " + "0; border: none;"); nameEdit->setContentsMargins(0, 0, 0, 0); nameEdit->setFixedWidth(80); // Height will be set dynamically below based on document height @@ -1091,13 +1099,15 @@ void OneSevenLiveCustomEventDialog::populateGiftTab(const OneSevenLiveGiftTab& g // Compute dynamic height from document layout (max two lines from elide), then add 5px qreal docHeight = nameEdit->document()->documentLayout()->documentSize().height(); int lineH = QFontMetrics(nameEdit->font()).lineSpacing(); - int minH = lineH; // at least 1 line - int maxH = lineH * 2; // at most 2 lines + int minH = lineH; // at least 1 line + int maxH = lineH * 2; // at most 2 lines int h = qRound(docHeight); - if (h < minH) h = minH; - if (h > maxH) h = maxH; + if (h < minH) + h = minH; + if (h > maxH) + h = maxH; nameEdit->setFixedHeight(h + 5); - } + } // Create price label QLabel* pointLabel = new QLabel(QString::number(gift.point)); diff --git a/src/17live/OneSevenLiveCustomEventDialog.hpp b/src/17live/OneSevenLiveCustomEventDialog.hpp index 8abdc6d..0d2d200 100644 --- a/src/17live/OneSevenLiveCustomEventDialog.hpp +++ b/src/17live/OneSevenLiveCustomEventDialog.hpp @@ -78,25 +78,25 @@ class OneSevenLiveCustomEventDialog : public QDialog { private: // UI Components - QVBoxLayout* mainLayout; + QVBoxLayout* mainLayout = nullptr; // Event Title Section - QLabel* titleLabel; - QLineEdit* eventTitleEdit; + QLabel* titleLabel = nullptr; + QLineEdit* eventTitleEdit = nullptr; // Event Date Section - QLabel* dateLabel; - QDateEdit* dateEdit; - QCalendarWidget* calendar; - QFrame* calendarFrame; + QLabel* dateLabel = nullptr; + QDateEdit* dateEdit = nullptr; + QCalendarWidget* calendar = nullptr; + QFrame* calendarFrame = nullptr; // Event Gifts Section - QLabel* giftsLabel; - QLineEdit* selectedGiftsEdit; - QTabWidget* giftTabWidget; - QScrollArea* giftsScrollArea; - QWidget* giftsContainer; - QGridLayout* giftsLayout; + QLabel* giftsLabel = nullptr; + QLineEdit* selectedGiftsEdit = nullptr; + QTabWidget* giftTabWidget = nullptr; + QScrollArea* giftsScrollArea = nullptr; + QWidget* giftsContainer = nullptr; + QGridLayout* giftsLayout = nullptr; QList selectedGifts; // Support multiple gift selection static const int MAX_SELECTED_GIFTS = 4; // Maximum 4 gifts can be selected @@ -106,24 +106,24 @@ class OneSevenLiveCustomEventDialog : public QDialog { QList filteredGiftTabs; // Event Targets Section - QLabel* dailyTargetLabel; - QLineEdit* dailyTargetEdit; - QLabel* totalTargetLabel; - QLineEdit* totalTargetEdit; + QLabel* dailyTargetLabel = nullptr; + QLineEdit* dailyTargetEdit = nullptr; + QLabel* totalTargetLabel = nullptr; + QLineEdit* totalTargetEdit = nullptr; // Event Description Section - QLabel* descriptionLabel; - QTextEdit* descriptionEdit; - QLabel* characterCountLabel; + QLabel* descriptionLabel = nullptr; + QTextEdit* descriptionEdit = nullptr; + QLabel* characterCountLabel = nullptr; // Bottom Buttons - QHBoxLayout* buttonLayout; - QPushButton* createButton; + QHBoxLayout* buttonLayout = nullptr; + QPushButton* createButton = nullptr; // API Wrapper - OneSevenLiveApiWrappers* apiWrapper; + OneSevenLiveApiWrappers* apiWrapper = nullptr; // Config manager - OneSevenLiveConfigManager* configManager; + OneSevenLiveConfigManager* configManager = nullptr; // Constants static const int MAX_TITLE_LENGTH = 20; diff --git a/src/17live/OneSevenLiveHttpServer.cpp b/src/17live/OneSevenLiveHttpServer.cpp index 731e500..c5e3df2 100644 --- a/src/17live/OneSevenLiveHttpServer.cpp +++ b/src/17live/OneSevenLiveHttpServer.cpp @@ -15,15 +15,8 @@ #include "OneSevenLiveCoreManager.hpp" #include "api/OneSevenLiveApiWrappers.hpp" #include "plugin-support.h" - -// Helper function to get module data path -std::string get_obs_module_data_path_str() { - const char* path = obs_get_module_data_path(obs_current_module()); - if (path) { - return std::string(path); - } - return ""; // Or throw exception, or return a default known path -} +#include "utility/Common.hpp" +#include "websocket/OneSevenLiveWebsocketServer.hpp" std::string OneSevenLiveHttpServer::get_file_extension(const std::string& file_path) const { size_t dot_pos = file_path.rfind('.'); @@ -63,11 +56,12 @@ std::string OneSevenLiveHttpServer::get_mime_type(const std::string& file_path) } OneSevenLiveHttpServer::OneSevenLiveHttpServer(const std::string& host, int port, - const std::string& base_dir_relative_to_module_data) - : host_(host), port_(port), running_(false) { + const std::string& base_dir_relative_to_module_data, + const std::string& name) + : host_(host), port_(port), running_(false), name_(name) { std::string module_data_path = get_obs_module_data_path_str(); if (module_data_path.empty()) { - blog(LOG_ERROR, "[17Live HTTP Server] Failed to get OBS module data path."); + obs_log(LOG_ERROR, "[%s] Failed to get OBS module data path.", name_.c_str()); // Can choose to set a default base_dir_ or let server startup fail base_dir_ = base_dir_relative_to_module_data; // Fallback or error state } else { @@ -76,40 +70,40 @@ OneSevenLiveHttpServer::OneSevenLiveHttpServer(const std::string& host, int port base_dir_ = full_base_path.string(); } - blog(LOG_INFO, "[17Live HTTP Server] Base directory set to: %s", base_dir_.c_str()); + obs_log(LOG_INFO, "[%s] Base directory set to: %s", name_.c_str(), base_dir_.c_str()); // Initialize CSRF token csrf_token_ = generate_csrf_token(); } OneSevenLiveHttpServer::~OneSevenLiveHttpServer() { - blog(LOG_INFO, "[17Live HTTP Server] Starting HTTP server destruction"); + obs_log(LOG_INFO, "[%s] Starting HTTP server destruction", name_.c_str()); // Ensure server is completely stopped and thread properly terminated stop(); // Additional safety check: ensure thread has completely finished if (server_thread_ && server_thread_->joinable()) { - blog(LOG_WARNING, - "[17Live HTTP Server] Thread still joinable in destructor, forcing thread termination " - "wait"); + obs_log(LOG_WARNING, + "[%s] Thread still joinable in destructor, forcing thread termination " + "wait", + name_.c_str()); server_thread_->join(); } - blog(LOG_INFO, "[17Live HTTP Server] HTTP server successfully destroyed"); + obs_log(LOG_INFO, "[%s] HTTP server successfully destroyed", name_.c_str()); } bool OneSevenLiveHttpServer::start() { if (running_) { - blog(LOG_WARNING, "[17Live HTTP Server] Server already running."); + obs_log(LOG_WARNING, "[%s] Server already running.", name_.c_str()); return true; } // Ensure base_dir_ exists if (!std::filesystem::exists(base_dir_) || !std::filesystem::is_directory(base_dir_)) { - blog(LOG_ERROR, - "[17Live HTTP Server] Base directory '%s' does not exist or is not a directory.", - base_dir_.c_str()); + obs_log(LOG_ERROR, "[%s] Base directory '%s' does not exist or is not a directory.", + name_.c_str(), base_dir_.c_str()); return false; } @@ -117,13 +111,14 @@ bool OneSevenLiveHttpServer::start() { // The second parameter of httplib's set_mount_point should be a path relative to current // working directory, or absolute path. We have already calculated base_dir_ as absolute path. if (!svr_.set_mount_point("/", base_dir_.c_str())) { - blog(LOG_ERROR, "[17Live HTTP Server] Failed to set mount point '/' to '%s'", - base_dir_.c_str()); + obs_log(LOG_ERROR, "[%s] Failed to set mount point '/' to '%s'", name_.c_str(), + base_dir_.c_str()); return false; } - blog(LOG_INFO, "[17Live HTTP Server] Mounting '/' to serve files from '%s'", base_dir_.c_str()); + obs_log(LOG_INFO, "[%s] Mounting '/' to serve files from '%s'", name_.c_str(), + base_dir_.c_str()); - // Override the default handler for static files to add security checks + // Override the default handler for static files svr_.Get("/.*", [this](const httplib::Request& req, httplib::Response& res) { // Security check: get client IP std::string client_ip = req.get_header_value("X-Forwarded-For"); @@ -164,16 +159,16 @@ bool OneSevenLiveHttpServer::start() { std::string content((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); if (ifs.bad()) { - blog(LOG_ERROR, "[17Live HTTP Server] Error reading file: %s", - file_path_str.c_str()); + obs_log(LOG_ERROR, "[%s] Error reading file: %s", name_.c_str(), + file_path_str.c_str()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } else { res.set_content(content, get_mime_type(file_path_str).c_str()); } } else { - blog(LOG_ERROR, "[17Live HTTP Server] Failed to open file: %s", - file_path_str.c_str()); + obs_log(LOG_ERROR, "[%s] Failed to open file: %s", name_.c_str(), + file_path_str.c_str()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } @@ -182,13 +177,13 @@ bool OneSevenLiveHttpServer::start() { res.set_content("Not Found", "text/plain"); } } catch (const std::filesystem::filesystem_error& e) { - blog(LOG_ERROR, "[17Live HTTP Server] Filesystem error for %s: %s", - file_path_str.c_str(), e.what()); + obs_log(LOG_ERROR, "[%s] Filesystem error for %s: %s", name_.c_str(), + file_path_str.c_str(), e.what()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } catch (const std::exception& e) { - blog(LOG_ERROR, "[17Live HTTP Server] Exception serving file %s: %s", - file_path_str.c_str(), e.what()); + obs_log(LOG_ERROR, "[%s] Exception serving file %s: %s", name_.c_str(), + file_path_str.c_str(), e.what()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } @@ -211,12 +206,12 @@ bool OneSevenLiveHttpServer::start() { return; } - obs_log(LOG_INFO, "[17Live HTTP Server] Handling request for %s from %s", req.path.c_str(), + obs_log(LOG_INFO, "[%s] Handling request for %s from %s", name_.c_str(), req.path.c_str(), client_ip.c_str()); // Security check: path validation if (!is_safe_path(req.path)) { - blog(LOG_WARNING, "[17Live HTTP Server] Unsafe path detected: %s", req.path.c_str()); + obs_log(LOG_WARNING, "[%s] Unsafe path detected: %s", name_.c_str(), req.path.c_str()); res.status = 403; res.set_content("Forbidden", "text/plain"); return; @@ -236,32 +231,49 @@ bool OneSevenLiveHttpServer::start() { std::string content((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); if (ifs.bad()) { - blog(LOG_ERROR, "[17Live HTTP Server] Error reading index.html: %s", - path_str.c_str()); + obs_log(LOG_ERROR, "[%s] Error reading index.html: %s", name_.c_str(), + path_str.c_str()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } else { res.set_content(content, get_mime_type(path_str).c_str()); } } else { - blog(LOG_WARNING, "[17Live HTTP Server] File not found for /: %s", - path_str.c_str()); + obs_log(LOG_WARNING, "[%s] File not found for /: %s", name_.c_str(), + path_str.c_str()); res.status = 404; res.set_content("File not found", "text/plain"); // Don't expose internal paths } } catch (const std::filesystem::filesystem_error& e) { - blog(LOG_ERROR, "[17Live HTTP Server] Filesystem error for index.html %s: %s", - path_str.c_str(), e.what()); + obs_log(LOG_ERROR, "[%s] Filesystem error for index.html %s: %s", name_.c_str(), + path_str.c_str(), e.what()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } catch (const std::exception& e) { - blog(LOG_ERROR, "[17Live HTTP Server] Exception serving index.html %s: %s", - path_str.c_str(), e.what()); + obs_log(LOG_ERROR, "[%s] Exception serving index.html %s: %s", name_.c_str(), + path_str.c_str(), e.what()); res.status = 500; res.set_content("Internal Server Error", "text/plain"); } }); + for (const auto& p : extra_get_) { + svr_.Get(p.first.c_str(), + [this, handler = p.second](const httplib::Request& req, httplib::Response& res) { + std::string client_ip = req.get_header_value("X-Forwarded-For"); + if (client_ip.empty()) + client_ip = req.get_header_value("X-Real-IP"); + if (client_ip.empty()) + client_ip = "127.0.0.1"; + if (!check_rate_limit(client_ip)) { + res.status = 429; + res.set_content("Too Many Requests", "text/plain"); + return; + } + handler(req, res); + }); + } + svr_.Get("/ping", [this](const httplib::Request& req, httplib::Response& res) { // Security check: rate limiting std::string client_ip = req.get_header_value("X-Forwarded-For"); @@ -308,147 +320,213 @@ bool OneSevenLiveHttpServer::start() { res.set_content(responseStr, "application/json"); }); - // Add /lapi route to handle API requests - svr_.Post("/lapi", [this](const httplib::Request& req, httplib::Response& res) { - // Security check: get client IP - std::string client_ip = req.get_header_value("X-Forwarded-For"); - if (client_ip.empty()) { - client_ip = req.get_header_value("X-Real-IP"); - } - if (client_ip.empty()) { - client_ip = "127.0.0.1"; // local request - } + if (enable_default_api_) { + svr_.Post("/lapi", [this](const httplib::Request& req, httplib::Response& res) { + // Security check: get client IP + std::string client_ip = req.get_header_value("X-Forwarded-For"); + if (client_ip.empty()) { + client_ip = req.get_header_value("X-Real-IP"); + } + if (client_ip.empty()) { + client_ip = "127.0.0.1"; // local request + } - // Security check: rate limiting - if (!check_rate_limit(client_ip)) { - res.status = 429; - res.set_header("Content-Type", "application/json"); - const nlohmann::json errorResponse = {{"success", false}, - {"error", "Rate limit exceeded"}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - return; - } + // Security check: rate limiting + if (!check_rate_limit(client_ip)) { + res.status = 429; + res.set_header("Content-Type", "application/json"); + const nlohmann::json errorResponse = {{"success", false}, + {"error", "Rate limit exceeded"}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; + } + + // Security check: request size validation + if (!validate_request_size(req)) { + res.status = 413; // Payload Too Large + res.set_header("Content-Type", "application/json"); + const nlohmann::json errorResponse = {{"success", false}, + {"error", "Request too large"}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; + } - // Security check: request size validation - if (!validate_request_size(req)) { - res.status = 413; // Payload Too Large + // obs_log(LOG_INFO, "[17Live HTTP Server] Handling API request to /lapi from %s", + // client_ip.c_str()); + + // Set response headers res.set_header("Content-Type", "application/json"); - const nlohmann::json errorResponse = {{"success", false}, - {"error", "Request too large"}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - return; - } + res.set_header("X-Content-Type-Options", "nosniff"); + res.set_header("X-Frame-Options", "DENY"); + res.set_header("X-XSS-Protection", "1; mode=block"); + + // Get OneSevenLiveCoreManager instance + auto& coreManager = OneSevenLiveCoreManager::getInstance(); + + // Parse JSON data from request body + nlohmann::json requestJson; + try { + requestJson = nlohmann::json::parse(req.body); + } catch (const nlohmann::json::parse_error& e) { + // JSON parsing error - pre-build error message to avoid repeated string operations + const std::string errorMsg = "Invalid JSON: " + std::string(e.what()); + const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; + } - // obs_log(LOG_INFO, "[17Live HTTP Server] Handling API request to /lapi from %s", - // client_ip.c_str()); + // Get requested action + if (!requestJson.contains("action") || !requestJson["action"].is_string()) { + // Missing action parameter + const nlohmann::json errorResponse = {{"success", false}, + {"error", "Missing 'action' parameter"}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; + } - // Set response headers - res.set_header("Content-Type", "application/json"); - res.set_header("X-Content-Type-Options", "nosniff"); - res.set_header("X-Frame-Options", "DENY"); - res.set_header("X-XSS-Protection", "1; mode=block"); + const std::string action = requestJson["action"].get(); - // Get OneSevenLiveCoreManager instance - auto& coreManager = OneSevenLiveCoreManager::getInstance(); + // Call API and return result + nlohmann::json apiResult; + bool success = false; - // Parse JSON data from request body - nlohmann::json requestJson; - try { - requestJson = nlohmann::json::parse(req.body); - } catch (const nlohmann::json::parse_error& e) { - // JSON parsing error - pre-build error message to avoid repeated string operations - const std::string errorMsg = "Invalid JSON: " + std::string(e.what()); - const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - return; - } + try { + // Get apiWrapper instance + auto apiWrapper = coreManager.getApiWrapper(); + auto configManager = coreManager.getConfigManager(); - // Get requested action - if (!requestJson.contains("action") || !requestJson["action"].is_string()) { - // Missing action parameter - const nlohmann::json errorResponse = {{"success", false}, - {"error", "Missing 'action' parameter"}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - return; - } + if (!apiWrapper) { + // API Wrapper not initialized + const nlohmann::json errorResponse = {{"success", false}, + {"error", "API not initialized"}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; + } - const std::string action = requestJson["action"].get(); + // Call corresponding API function based on action + if (action == ACTION_GETABLYTOKEN) { + std::string roomID; + configManager->getConfigValue("RoomID", roomID); + success = apiWrapper->GetAblyToken(roomID, apiResult); + } else if (action == ACTION_GETGIFTS) { + if (!configManager->loadGifts(apiResult)) { + if (coreManager.isGiftsLoading()) { + obs_log(LOG_INFO, + "[%s] Gifts loading in progress, returning wait response", + name_.c_str()); + const nlohmann::json response = {{"success", false}, + {"error", "Gifts loading"}}; + res.set_content(response.dump(), "application/json"); + return; + } + + std::string language; + configManager->getConfigValue("Region", language); + success = apiWrapper->GetGifts(language, apiResult); + configManager->saveGifts(apiResult); + } else { + success = true; + } + } else if (action == ACTION_GETGIFT) { + if (coreManager.isGiftsLoading()) { + obs_log(LOG_INFO, "[%s] Gifts loading in progress, returning wait response", + name_.c_str()); + const nlohmann::json response = {{"success", false}, + {"error", "Gifts loading"}}; + res.set_content(response.dump(), "application/json"); + return; + } + std::string giftID; + if (requestJson.contains("giftID") && requestJson["giftID"].is_string()) + giftID = requestJson["giftID"].get(); - // Call API and return result - nlohmann::json apiResult; - bool success = false; + std::optional gift; + if (!giftID.empty()) { + gift = OneSevenLiveCoreManager::getInstance().getGiftByID(giftID); + } - try { - // Get apiWrapper instance - auto apiWrapper = coreManager.getApiWrapper(); - auto configManager = coreManager.getConfigManager(); + if (gift) { + apiResult = *gift; + success = true; + } else { + obs_log(LOG_WARNING, "Gift not found. giftID=%s", giftID.c_str()); + const nlohmann::json errorResponse = {{"success", false}, + {"error", "Gift not found"}}; + res.set_content(errorResponse.dump(), "application/json"); + return; + } - if (!apiWrapper) { - // API Wrapper not initialized - const nlohmann::json errorResponse = {{"success", false}, - {"error", "API not initialized"}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - return; - } + } else if (action == ACTION_GETROOMINFO) { + OneSevenLiveLoginData loginData; + configManager->getLoginData(loginData); - // Call corresponding API function based on action - if (action == ACTION_GETABLYTOKEN) { - std::string roomID; - configManager->getConfigValue("RoomID", roomID); - success = apiWrapper->GetAblyToken(roomID, apiResult); - } else if (action == ACTION_GETGIFTS) { - if (!configManager->loadGifts(apiResult)) { - std::string language; - configManager->getConfigValue("Region", language); - success = apiWrapper->GetGifts(language, apiResult); - configManager->saveGifts(apiResult); + OneSevenLiveRoomInfo roomInfo; + success = apiWrapper->GetRoomInfo(loginData.userInfo.roomID, roomInfo); + if (success) { + OneSevenLiveRoomInfoToJson(roomInfo, apiResult); + } } else { - success = true; + // Unsupported action - pre-build error message + const std::string errorMsg = "Unsupported action: " + action; + const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; } - } else if (action == ACTION_GETROOMINFO) { - OneSevenLiveLoginData loginData; - configManager->getLoginData(loginData); - - OneSevenLiveRoomInfo roomInfo; - success = apiWrapper->GetRoomInfo(loginData.userInfo.roomID, roomInfo); - if (success) { - OneSevenLiveRoomInfoToJson(roomInfo, apiResult); + + if (!success) { + // API call failed - pre-convert error message + const std::string errorMsg = apiWrapper->getLastErrorMessage().toStdString(); + const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; + const std::string responseStr = errorResponse.dump(); + res.set_content(responseStr, "application/json"); + return; } - } else { - // Unsupported action - pre-build error message - const std::string errorMsg = "Unsupported action: " + action; + + // Build response - cache dump result + const nlohmann::json response = apiResult; + const std::string responseStr = response.dump(); + res.set_content(responseStr, "application/json"); + } catch (const std::exception& e) { + // Handle exceptions - pre-build error message + const std::string errorMsg = std::string("Exception: ") + e.what(); const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; const std::string responseStr = errorResponse.dump(); res.set_content(responseStr, "application/json"); - return; } + }); + } - if (!success) { - // API call failed - pre-convert error message - const std::string errorMsg = apiWrapper->getLastErrorMessage().toStdString(); - const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); + for (const auto& p : extra_post_) { + svr_.Post(p.first.c_str(), [this, handler = p.second](const httplib::Request& req, + httplib::Response& res) { + std::string client_ip = req.get_header_value("X-Forwarded-For"); + if (client_ip.empty()) + client_ip = req.get_header_value("X-Real-IP"); + if (client_ip.empty()) + client_ip = "127.0.0.1"; + if (!check_rate_limit(client_ip)) { + res.status = 429; + res.set_header("Content-Type", "application/json"); + const nlohmann::json err = {{"success", false}, {"error", "Rate limit exceeded"}}; + res.set_content(err.dump(), "application/json"); return; } - - // Build response - cache dump result - const nlohmann::json response = apiResult; - const std::string responseStr = response.dump(); - res.set_content(responseStr, "application/json"); - } catch (const std::exception& e) { - // Handle exceptions - pre-build error message - const std::string errorMsg = std::string("Exception: ") + e.what(); - const nlohmann::json errorResponse = {{"success", false}, {"error", errorMsg}}; - const std::string responseStr = errorResponse.dump(); - res.set_content(responseStr, "application/json"); - } - }); + if (!validate_request_size(req)) { + res.status = 413; + res.set_header("Content-Type", "application/json"); + const nlohmann::json err = {{"success", false}, {"error", "Request too large"}}; + res.set_content(err.dump(), "application/json"); + return; + } + handler(req, res); + }); + } // Start server in new thread to avoid blocking main thread server_thread_ = std::make_unique([this]() { @@ -457,32 +535,32 @@ bool OneSevenLiveHttpServer::start() { // Bind to any available port if port_ is 0 port_ = svr_.bind_to_any_port(host_.c_str()); if (port_ < 0) { // bind_to_any_port returns -1 on failure - blog(LOG_ERROR, "[17Live HTTP Server] Failed to bind to any port on %s: %s", - host_.c_str(), std::strerror(errno)); + obs_log(LOG_ERROR, "[%s] Failed to bind to any port on %s: %s", name_.c_str(), + host_.c_str(), std::strerror(errno)); running_ = false; return; } - blog(LOG_INFO, "[17Live HTTP Server] Bound to %s:%d", host_.c_str(), port_); + obs_log(LOG_INFO, "[%s] Bound to %s:%d", name_.c_str(), host_.c_str(), port_); if (!svr_.listen_after_bind()) { - blog(LOG_ERROR, "[17Live HTTP Server] Failed to listen on %s:%d after bind: %s", - host_.c_str(), port_, std::strerror(errno)); + obs_log(LOG_ERROR, "[%s] Failed to listen on %s:%d after bind: %s", + name_.c_str(), host_.c_str(), port_, std::strerror(errno)); running_ = false; } } else { // Listen on the specified port - blog(LOG_INFO, "[17Live HTTP Server] Starting server on %s:%d", host_.c_str(), - port_); + obs_log(LOG_INFO, "[%s] Starting server on %s:%d", name_.c_str(), host_.c_str(), + port_); if (!svr_.listen(host_.c_str(), port_)) { - blog(LOG_ERROR, "[17Live HTTP Server] Failed to listen on %s:%d: %s", - host_.c_str(), port_, std::strerror(errno)); + obs_log(LOG_ERROR, "[%s] Failed to listen on %s:%d: %s", name_.c_str(), + host_.c_str(), port_, std::strerror(errno)); running_ = false; // Ensure correct state } } } catch (const std::exception& e) { - blog(LOG_ERROR, "[17Live HTTP Server] Exception during server startup: %s", e.what()); + obs_log(LOG_ERROR, "[%s] Exception during server startup: %s", name_.c_str(), e.what()); running_ = false; } catch (...) { - blog(LOG_ERROR, "[17Live HTTP Server] Unknown exception during server startup"); + obs_log(LOG_ERROR, "[%s] Unknown exception during server startup", name_.c_str()); running_ = false; } }); @@ -505,7 +583,7 @@ bool OneSevenLiveHttpServer::start() { // called, running_ will be false But if listen is trying, it will block, is_running() may // still be false This is a simplified handling, actual projects may need more complex // startup confirmation mechanism - blog(LOG_INFO, "[17Live HTTP Server] Server thread started. Checking status shortly."); + obs_log(LOG_INFO, "[%s] Server thread started. Checking status shortly.", name_.c_str()); // Temporarily assume startup success, let stop and destructor handle cleanup running_ = true; } @@ -513,19 +591,53 @@ bool OneSevenLiveHttpServer::start() { return running_; } +void OneSevenLiveHttpServer::addGetHandler( + const std::string& pattern, + std::function handler) { + extra_get_.push_back({pattern, std::move(handler)}); +} + +void OneSevenLiveHttpServer::addPostHandler( + const std::string& pattern, + std::function handler) { + extra_post_.push_back({pattern, std::move(handler)}); +} + +void OneSevenLiveHttpServer::setEnableDefaultApi(bool enable) { + enable_default_api_ = enable; +} + void OneSevenLiveHttpServer::stop() { if (running_) { - blog(LOG_INFO, "[17Live HTTP Server] Stopping server..."); + obs_log(LOG_INFO, "[%s] Stopping server...", name_.c_str()); svr_.stop(); // Stop server listening if (server_thread_ && server_thread_->joinable()) { server_thread_->join(); // Wait for server thread to end } server_thread_.reset(); running_ = false; - blog(LOG_INFO, "[17Live HTTP Server] Server stopped."); + obs_log(LOG_INFO, "[%s] Server stopped.", name_.c_str()); } else { - // blog(LOG_INFO, "[17Live HTTP Server] Server not running or already stopped."); + // obs_log(LOG_INFO, "[%s] Server not running or already stopped.", name_.c_str()); + } +} + +void OneSevenLiveHttpServer::stopAsync() { + if (!running_ || stopping_.load()) { + return; } + stopping_.store(true); + obs_log(LOG_INFO, "[%s] Stopping server...", name_.c_str()); + svr_.stop(); + std::thread([this]() { + if (server_thread_ && server_thread_->joinable()) { + server_thread_->join(); + } + server_thread_.reset(); + running_ = false; + stopping_.store(false); + obs_log(LOG_INFO, "[%s] Server stopped.", name_.c_str()); + }).detach(); } bool OneSevenLiveHttpServer::is_running() const { @@ -589,7 +701,8 @@ bool OneSevenLiveHttpServer::check_rate_limit(const std::string& client_ip) { // Check if rate limit is exceeded if (requests.size() >= RATE_LIMIT_REQUESTS) { - blog(LOG_WARNING, "[17Live HTTP Server] Rate limit exceeded for IP: %s", client_ip.c_str()); + obs_log(LOG_WARNING, "[%s] Rate limit exceeded for IP: %s", name_.c_str(), + client_ip.c_str()); return false; } @@ -600,8 +713,8 @@ bool OneSevenLiveHttpServer::check_rate_limit(const std::string& client_ip) { bool OneSevenLiveHttpServer::validate_request_size(const httplib::Request& req) const { if (req.body.size() > MAX_REQUEST_SIZE) { - blog(LOG_WARNING, "[17Live HTTP Server] Request size too large: %zu bytes", - req.body.size()); + obs_log(LOG_WARNING, "[%s] Request size too large: %zu bytes", name_.c_str(), + req.body.size()); return false; } return true; diff --git a/src/17live/OneSevenLiveHttpServer.hpp b/src/17live/OneSevenLiveHttpServer.hpp index b09f35e..f11a23c 100644 --- a/src/17live/OneSevenLiveHttpServer.hpp +++ b/src/17live/OneSevenLiveHttpServer.hpp @@ -3,25 +3,35 @@ #include #include +#include #include #include #include #include #include +#include #include "../../deps/cpp-httplib/httplib.h" class OneSevenLiveHttpServer { public: OneSevenLiveHttpServer(const std::string& host, int port = 0, - const std::string& base_dir_relative_to_module_data = "html"); + const std::string& base_dir_relative_to_module_data = "html", + const std::string& name = "17Live HTTP Server"); ~OneSevenLiveHttpServer(); bool start(); void stop(); + void stopAsync(); bool is_running() const; int getPort() const; + void addGetHandler(const std::string& pattern, + std::function handler); + void addPostHandler(const std::string& pattern, + std::function handler); + void setEnableDefaultApi(bool enable); + private: std::string get_mime_type(const std::string& file_path) const; std::string get_file_extension(const std::string& file_path) const; @@ -39,6 +49,15 @@ class OneSevenLiveHttpServer { std::string base_dir_; std::unique_ptr server_thread_; bool running_ = false; + std::atomic stopping_{false}; + + std::vector< + std::pair>> + extra_get_; + std::vector< + std::pair>> + extra_post_; + bool enable_default_api_ = true; // Security-related member variables static constexpr size_t MAX_REQUEST_SIZE = 1024 * 1024; // 1MB @@ -49,6 +68,7 @@ class OneSevenLiveHttpServer { std::unordered_map> rate_limit_map_; std::string csrf_token_; + std::string name_; }; #endif // ONESEVENLIVEHTTPSERVER_HPP diff --git a/src/17live/OneSevenLiveLoginDialog.cpp b/src/17live/OneSevenLiveLoginDialog.cpp index 8e31520..0b9bf92 100644 --- a/src/17live/OneSevenLiveLoginDialog.cpp +++ b/src/17live/OneSevenLiveLoginDialog.cpp @@ -333,7 +333,6 @@ void OneSevenLiveLoginDialog::handleLogin() { OneSevenLiveLoginData loginData; obs_log(LOG_INFO, "Login username: [%s]", usernameEdit->text().toStdString().c_str()); - // output username's unicode values for emoji tracking QString username = usernameEdit->text(); QString unicodeStr = "Username unicode values: "; @@ -346,7 +345,6 @@ void OneSevenLiveLoginDialog::handleLogin() { // trip whitespace QString trimmedUsername = usernameEdit->text().trimmed(); - // Call login interface if (!apiWrapper->Login(trimmedUsername, passwordEdit->text(), loginData)) { // QString errorMessageTemplate = obs_module_text("Auth.Error02"); @@ -362,8 +360,6 @@ void OneSevenLiveLoginDialog::handleLogin() { // obs_log(LOG_INFO, "displayName: %s", loginData.userInfo.displayName.toStdString().c_str()); // obs_log(LOG_INFO, "roomID: %d", loginData.userInfo.roomID); - emit loginSuccess(loginData); - // log access token // obs_log(LOG_INFO, "access token: %s", loginData.accessToken.toStdString().c_str()); @@ -372,6 +368,7 @@ void OneSevenLiveLoginDialog::handleLogin() { this, obs_module_text("Auth.LoginSuccess"), QString(obs_module_text("Auth.LoginSuccess.Tip")).arg(loginData.userInfo.openID)); + emit loginSuccess(loginData); // Login successful accept(); } diff --git a/src/17live/OneSevenLiveLoginDialog.hpp b/src/17live/OneSevenLiveLoginDialog.hpp index 804389d..be61179 100644 --- a/src/17live/OneSevenLiveLoginDialog.hpp +++ b/src/17live/OneSevenLiveLoginDialog.hpp @@ -29,18 +29,16 @@ class OneSevenLiveLoginDialog : public QDialog { void loginSuccess(const OneSevenLiveLoginData& loginData); private: - QLabel* titleLabel; - QLineEdit* usernameEdit; - QLineEdit* passwordEdit; - QPushButton* showPasswordButton; - QPushButton* loginButton; - QLabel* errorLabel; - QWidget* errorContainer; - QLabel* forgotPasswordLabel; - QLabel* registerLabel; - QLabel* disclaimerLabel; - QLabel* passwordLabel; - QPushButton* passwordQuestionButton; - QLabel* forgotPasswordLinkLabel; - OneSevenLiveApiWrappers* apiWrapper; + QLineEdit* usernameEdit = nullptr; + QLineEdit* passwordEdit = nullptr; + QPushButton* showPasswordButton = nullptr; + QPushButton* loginButton = nullptr; + QLabel* errorLabel = nullptr; + QWidget* errorContainer = nullptr; + QLabel* registerLabel = nullptr; + QLabel* disclaimerLabel = nullptr; + QLabel* passwordLabel = nullptr; + QPushButton* passwordQuestionButton = nullptr; + QLabel* forgotPasswordLinkLabel = nullptr; + OneSevenLiveApiWrappers* apiWrapper = nullptr; }; diff --git a/src/17live/OneSevenLiveMenuManager.cpp b/src/17live/OneSevenLiveMenuManager.cpp index 82d708f..4058200 100644 --- a/src/17live/OneSevenLiveMenuManager.cpp +++ b/src/17live/OneSevenLiveMenuManager.cpp @@ -14,9 +14,11 @@ OneSevenLiveMenuManager::OneSevenLiveMenuManager(QMainWindow* parent) isChatRoomVisible(false), isBroadcastVisible(false), isLiveListVisible(false), - isRockZoneVisible(false) { + isRockZoneVisible(false), + isMultiRtmpVisible(false), + isPreviewDockVisible(false) { // Create 17Live menu - menu = mainWindow->menuBar()->addMenu(obs_module_text("17Live")); + menu = mainWindow->menuBar()->addMenu(obs_module_text("17LIVE")); // Add submenu for dock menu dockSubMenu = new QMenu(obs_module_text("Menu.Dock")); @@ -29,12 +31,18 @@ OneSevenLiveMenuManager::OneSevenLiveMenuManager(QMainWindow* parent) broadcastAction = dockSubMenu->addAction(obs_module_text("Menu.Broadcast")); connect(broadcastAction, &QAction::triggered, this, [this]() { emit streamingClicked(); }); - // rockZoneAction = dockSubMenu->addAction(obs_module_text("Menu.RockZone")); - // connect(rockZoneAction, &QAction::triggered, this, [this]() { emit rockZoneClicked(); }); + rockZoneAction = dockSubMenu->addAction(obs_module_text("Menu.RockZone")); + connect(rockZoneAction, &QAction::triggered, this, [this]() { emit rockZoneClicked(); }); liveListAction = dockSubMenu->addAction(obs_module_text("Menu.LiveList")); connect(liveListAction, &QAction::triggered, this, [this]() { emit liveListClicked(); }); + multiRtmpAction = dockSubMenu->addAction(obs_module_text("MultiRTMP.Dock.Title")); + connect(multiRtmpAction, &QAction::triggered, this, [this]() { emit multiRtmpClicked(); }); + + previewDockAction = dockSubMenu->addAction(obs_module_text("Menu.PreviewDock")); + connect(previewDockAction, &QAction::triggered, this, [this]() { emit previewDockClicked(); }); + menu->addSeparator(); // Common menu @@ -50,6 +58,10 @@ OneSevenLiveMenuManager::OneSevenLiveMenuManager(QMainWindow* parent) checkUpdateAction = menu->addAction(obs_module_text("Menu.CheckUpdate")); connect(checkUpdateAction, &QAction::triggered, this, &OneSevenLiveMenuManager::checkUpdate); + // Create diagnostics menu item + diagnosticsAction = menu->addAction(obs_module_text("Menu.Diagnostics")); + connect(diagnosticsAction, &QAction::triggered, this, [this]() { emit diagnosticsClicked(); }); + menu->addSeparator(); // Create login menu item @@ -63,11 +75,15 @@ OneSevenLiveMenuManager::OneSevenLiveMenuManager(QMainWindow* parent) chatRoomAction->setCheckable(true); broadcastAction->setCheckable(true); liveListAction->setCheckable(true); - // rockZoneAction->setCheckable(true); + rockZoneAction->setCheckable(true); + multiRtmpAction->setCheckable(true); + previewDockAction->setCheckable(true); chatRoomAction->setChecked(false); broadcastAction->setChecked(false); liveListAction->setChecked(false); - // rockZoneAction->setChecked(false); + rockZoneAction->setChecked(false); + multiRtmpAction->setChecked(false); + previewDockAction->setChecked(false); } OneSevenLiveMenuManager::~OneSevenLiveMenuManager() {} @@ -109,12 +125,15 @@ void OneSevenLiveMenuManager::checkUpdate() { } void OneSevenLiveMenuManager::updateDockVisibility(bool chatRoomVisible, bool broadcastVisible, - bool liveListVisible, bool rockZoneVisible) { + bool liveListVisible, bool rockZoneVisible, + bool multiRtmpVisible, bool previewDockVisible) { // Update visibility status variables isChatRoomVisible = chatRoomVisible; isBroadcastVisible = broadcastVisible; isLiveListVisible = liveListVisible; isRockZoneVisible = rockZoneVisible; + isMultiRtmpVisible = multiRtmpVisible; + isPreviewDockVisible = previewDockVisible; // Update menu item checked status if (chatRoomAction) { @@ -132,10 +151,20 @@ void OneSevenLiveMenuManager::updateDockVisibility(bool chatRoomVisible, bool br liveListAction->setChecked(isLiveListVisible); } - // if (rockZoneAction) { - // rockZoneAction->setCheckable(true); - // rockZoneAction->setChecked(isRockZoneVisible); - // } + if (rockZoneAction) { + rockZoneAction->setCheckable(true); + rockZoneAction->setChecked(isRockZoneVisible); + } + + if (multiRtmpAction) { + multiRtmpAction->setCheckable(true); + multiRtmpAction->setChecked(isMultiRtmpVisible); + } + + if (previewDockAction) { + previewDockAction->setCheckable(true); + previewDockAction->setChecked(isPreviewDockVisible); + } } void OneSevenLiveMenuManager::updateMenuItemsEnabled() { @@ -143,17 +172,20 @@ void OneSevenLiveMenuManager::updateMenuItemsEnabled() { chatRoomAction->setEnabled(isLoggedIn); broadcastAction->setEnabled(isLoggedIn); liveListAction->setEnabled(isLoggedIn); - // rockZoneAction->setEnabled(isLoggedIn); + rockZoneAction->setEnabled(isLoggedIn); + multiRtmpAction->setEnabled(isLoggedIn); + previewDockAction->setEnabled(isLoggedIn); + // Diagnostics action is always enabled regardless of login status } void OneSevenLiveMenuManager::cleanup() { if (dockSubMenu) { - delete dockSubMenu; + dockSubMenu->deleteLater(); dockSubMenu = nullptr; } if (menu) { - delete menu; + menu->deleteLater(); menu = nullptr; } @@ -163,4 +195,5 @@ void OneSevenLiveMenuManager::cleanup() { helpAction = nullptr; loginAction = nullptr; checkUpdateAction = nullptr; + diagnosticsAction = nullptr; } diff --git a/src/17live/OneSevenLiveMenuManager.hpp b/src/17live/OneSevenLiveMenuManager.hpp index ca77034..16adfb7 100644 --- a/src/17live/OneSevenLiveMenuManager.hpp +++ b/src/17live/OneSevenLiveMenuManager.hpp @@ -28,7 +28,8 @@ class OneSevenLiveMenuManager : public QObject { // Update dock window visibility status void updateDockVisibility(bool chatRoomVisible, bool broadcastVisible, bool liveListVisible, - bool rockZoneVisible = false); + bool rockZoneVisible = false, bool multiRtmpVisible = false, + bool previewDockVisible = false); // Update menu item enable status void updateMenuItemsEnabled(); @@ -39,28 +40,36 @@ class OneSevenLiveMenuManager : public QObject { void streamingClicked(); void liveListClicked(); void rockZoneClicked(); + void multiRtmpClicked(); + void previewDockClicked(); void helpClicked(); void loginClicked(); void logoutClicked(); void checkUpdateClicked(); + void diagnosticsClicked(); private: - QMainWindow* mainWindow; - QMenu* menu; - QMenu* dockSubMenu; - QAction* chatRoomAction; - QAction* settingsAction; - QAction* broadcastAction; - QAction* liveListAction; - QAction* rockZoneAction; - QAction* helpAction; - QAction* checkUpdateAction; - QAction* loginAction; - bool isLoggedIn; + QMainWindow* mainWindow = nullptr; + QMenu* menu = nullptr; + QMenu* dockSubMenu = nullptr; + QAction* chatRoomAction = nullptr; + QAction* settingsAction = nullptr; + QAction* broadcastAction = nullptr; + QAction* liveListAction = nullptr; + QAction* rockZoneAction = nullptr; + QAction* multiRtmpAction = nullptr; + QAction* previewDockAction = nullptr; + QAction* helpAction = nullptr; + QAction* checkUpdateAction = nullptr; + QAction* diagnosticsAction = nullptr; + QAction* loginAction = nullptr; + bool isLoggedIn = false; // Dock window visibility status - bool isChatRoomVisible; - bool isBroadcastVisible; - bool isLiveListVisible; - bool isRockZoneVisible; + bool isChatRoomVisible = false; + bool isBroadcastVisible = false; + bool isLiveListVisible = false; + bool isRockZoneVisible = false; + bool isMultiRtmpVisible = false; + bool isPreviewDockVisible = false; }; diff --git a/src/17live/OneSevenLiveStreamingDock.hpp b/src/17live/OneSevenLiveStreamingDock.hpp deleted file mode 100644 index 997c061..0000000 --- a/src/17live/OneSevenLiveStreamingDock.hpp +++ /dev/null @@ -1,174 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "api/OneSevenLiveModels.hpp" - -class OneSevenLiveApiWrappers; -class OneSevenLiveConfigManager; -class OneSevenLiveCustomEventDialog; - -class OneSevenLiveStreamingDock : public QDockWidget { - Q_OBJECT - - public: - explicit OneSevenLiveStreamingDock(QWidget *parent = nullptr, - OneSevenLiveApiWrappers *apiWrapper = nullptr, - OneSevenLiveConfigManager *configManager = nullptr); - ~OneSevenLiveStreamingDock(); - - void updateLiveStatus(OneSevenLiveStreamingStatus status); - void createLiveWithRequest(const OneSevenLiveRtmpRequest &request); - void editLiveWithInfo(const OneSevenLiveStreamInfo &info); - void loadRoomInfo(qint64 roomID); - - void closeLive(const std::string &currUserID, const std::string &currLiveStreamID, - bool isAutoClose = false); - - private: - void setupUi(); - void createConnections(); - void updateUIWithRoomInfo(); - void updateRequiredArmyRankSelections(); - void updateUIValues(); - - private: - // UI elements - QLineEdit *titleEdit; - QComboBox *categoryCombo; - - // Tag area - QLineEdit *tagEdit; - QPushButton *addTagButton; - QWidget *tagsContainer; // Container for displaying tags - QHBoxLayout *tagsLayout; // Layout for tag container - QList tagsList; // Store current tag list - - // Streaming format - QRadioButton *landscapeStreamRadio; - QRadioButton *portraitStreamRadio; - - // Live mode - army-only viewing - QLabel *broadcastModeLabel; - QWidget *armyOnlyHeader; - QHBoxLayout *armyOnlyHeaderLayout; - QLabel *armyOnlyLabel; - QPushButton *armyOnlyToggleButton; - QWidget *armyOnlyContainer; - QVBoxLayout *armyOnlyContainerLayout; - QCheckBox *armyOnlyCheck; - QComboBox *requiredArmyRankCombo; - QCheckBox *showInHotPageCheck; - QCheckBox *liveNotificationCheck; - bool armyOnlyExpanded; - - QComboBox *eventCombo; - QLabel *hintLabel; // Event hint label - QComboBox *customeventCombo; - QComboBox *viewerLimitCombo; - - // Custom Event - QWidget *customEventHeader; - QHBoxLayout *customEventHeaderLayout; - QLabel *customEventLabel; - QPushButton *customEventToggleButton; - OneSevenLiveCustomEventDialog *customEventDialog = nullptr; - bool customEventDialogVisible; - - // Party Live - QWidget *GroupCallContainer; - QHBoxLayout *GroupCallContainerLayout; - QLabel *GroupCallLabel; - QPushButton *GroupCallHelpButton; - QCheckBox *GroupCallCheck; - - // Switches - QCheckBox *archiveStreamCheck; - QCheckBox *autoPreviewCheck; - - QComboBox *clipIdentityCombo; - QCheckBox *virtualStreamerCheck; - - // Bottom buttons - QPushButton *saveConfigButton; - QPushButton *createLiveButton; - - // Loading state UI - QWidget *loadingOverlay; - QProgressBar *loadingProgress; - QLabel *loadingLabel; - - OneSevenLiveRoomInfo roomInfo; - OneSevenLiveConfigStreamer configStreamer; - OneSevenLiveUserInfo userInfo; - OneSevenLiveArmySubscriptionLevels levels; - OneSevenLiveCustomEvent customEvent; - - signals: - void streamInfoSaved(); - void streamStatusUpdated(OneSevenLiveStreamingStatus status); - - private slots: - void onAddTagClicked(); - void onTagEnterPressed(); - void onRemoveTagClicked(); - void onCreateLiveClicked(); - void onDeleteLiveClicked(); - void onSaveConfigClicked(); - void onArmyOnlyToggleClicked(); // New collapse/expand button click event - void onArmyOnlyCheckChanged(int state); // Triggered when armyOnlyCheck state changes - void onCustomEventToggleClicked(); // Custom event toggle button click event - void onGroupCallHelpClicked(); // Party live help button click event - void onEventChanged(int index); // Event change event handler - void onEventCooldownTimeout(); // Event cooldown timer timeout handler - void startEventCooldown(); // Start event cooldown timer - - private: - bool gatherRtmpRequest(OneSevenLiveRtmpRequest &request); - void populateRtmpRequest(const OneSevenLiveRtmpRequest &request); - void updateLiveButton(bool isLive); - - void saveStreamingSettings(const std::string &liveStreamID, const std::string &streamUrl, - const std::string &streamKey); - void saveWhipStreamingSettings(const std::string &liveStreamID, const std::string &whipServer, - const std::string &whipToken); - void stopStreaming(); - - void createLive(const OneSevenLiveRtmpRequest &request); - void startLive(const std::string userID, const OneSevenLiveRtmpResponse &response, - bool autoRecording, bool skip = false); - - void syncWithWeb(OneSevenLiveStreamingStatus status); - - // Tag-related functions - void addTag(const QString &tag); - void updateTagsFromList(); - int hashtagSelectLimit = 2; // Maximum number of tags that can be added - - OneSevenLiveApiWrappers *apiWrapper = nullptr; - OneSevenLiveConfigManager *configManager = nullptr; - - QString currentInfoUuid = ""; - bool isLoading = false; // Indicates whether loading is in progress - OneSevenLiveStreamingStatus currentLiveStatus = OneSevenLiveStreamingStatus::NotStarted; - - // Category change cooldown timer - QTimer *eventCooldownTimer = nullptr; - int eventCooldownRemaining = 0; // Remaining cooldown time in seconds - QString originalCategoryText = ""; // Original category text before cooldown - int previousEventIndex = -1; // Store previous event index for confirmation dialog - - protected: - void resizeEvent(QResizeEvent *event) override; -}; diff --git a/src/17live/QCefView.cpp b/src/17live/QCefView.cpp deleted file mode 100644 index 2a15258..0000000 --- a/src/17live/QCefView.cpp +++ /dev/null @@ -1,167 +0,0 @@ -#include "QCefView.hpp" - -#include -#include -#include // For os_event_t, etc. -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include // For DStr - -#ifdef Q_OS_WIN -#include -#include -#endif - -#include "SimpleCefClient.hpp" -#include "moc_QCefView.cpp" -#include "plugin-support.h" - -QCefView::QCefView(QWidget *parent) : QWidget(parent), m_client(nullptr) { - // Create layout - m_layout = new QVBoxLayout(this); - m_layout->setContentsMargins(0, 0, 0, 0); - setLayout(m_layout); - - // Create window container - m_window = new QWindow(); - m_container = QWidget::createWindowContainer(m_window, this); - m_layout->addWidget(m_container); - - // Set window attributes - setAttribute(Qt::WA_NativeWindow, true); - setAttribute(Qt::WA_DeleteOnClose, true); -} - -QCefView::~QCefView() { - if (m_client && m_client->getBrowser()) { - obs_log(LOG_INFO, "Starting QCefView destruction, closing CEF browser"); - auto browser = m_client->getBrowser(); - auto host = browser->GetHost(); - - // Request browser closure - host->CloseBrowser(true); - - // Wait for browser to completely close to avoid resource leaks - // Use timeout mechanism to prevent infinite waiting - int timeout_ms = 5000; // 5 second timeout - int wait_interval_ms = 10; - int elapsed_ms = 0; - - while (elapsed_ms < timeout_ms) { - if (!browser->GetHost()->TryCloseBrowser()) { - // Browser has closed - break; - } - - // Brief wait before retry - std::this_thread::sleep_for(std::chrono::milliseconds(wait_interval_ms)); - elapsed_ms += wait_interval_ms; - } - - if (elapsed_ms >= timeout_ms) { - obs_log(LOG_WARNING, "CEF browser close timeout, potential resource leak risk"); - } else { - obs_log(LOG_INFO, "CEF browser closed successfully"); - } - } -} - -void QCefView::loadUrl(const QString &url) { - m_currentUrl = url; - if (m_client && m_client->getBrowser()) { - CefString cefUrl(url.toStdString()); - m_client->getBrowser()->GetMainFrame()->LoadURL(cefUrl); - } else { - // Ensure window has correct size, but only call when widget has valid parent and screen - adjustSize(); - - // Use QTimer to delay browser creation, ensuring window size is properly set - QTimer::singleShot(100, this, [this, url]() { - // Create browser window - CefWindowInfo windowInfo; - -#ifdef Q_OS_WIN - // Get device pixel ratio for high DPI support - QScreen *currentScreen = screen(); - qreal devicePixelRatio = currentScreen ? currentScreen->devicePixelRatio() : 1.0; - int scaledWidth = width() * devicePixelRatio; - int scaledHeight = height() * devicePixelRatio; - - windowInfo.SetAsChild((CefWindowHandle) m_window->winId(), - CefRect(0, 0, scaledWidth, scaledHeight)); -#else - windowInfo.SetAsChild((CefWindowHandle)m_window->winId(), - CefRect(0, 0, width(), height())); -#endif - - CefBrowserSettings browserSettings; - - // Enable high DPI support - // browserSettings.windowless_frame_rate = 60; - - CefString cefUrl(url.toStdString()); - - m_client = new SimpleCefClient(); - CefBrowserHost::CreateBrowser(windowInfo, m_client.get(), cefUrl, browserSettings, - nullptr, nullptr); - - // Ensure browser window fills the entire container - QTimer::singleShot(200, this, [this]() { - if (m_client->getBrowser()) { - resizeEvent(nullptr); - } - }); - }); - } -} - -QString QCefView::currentUrl() const { - return m_currentUrl; -} - -void QCefView::reload() { - if (m_client && m_client->getBrowser()) { - obs_log(LOG_INFO, "QCefView::reload() - Reloading current page"); - m_client->getBrowser()->Reload(); - } else { - obs_log(LOG_WARNING, "QCefView::reload() - Browser not initialized, cannot reload"); - } -} - -void QCefView::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - if (m_client && m_client->getBrowser()) { - CefWindowHandle hwnd = m_client->getBrowser()->GetHost()->GetWindowHandle(); - if (hwnd) { -#ifdef Q_OS_WIN - // Get device pixel ratio for high DPI support - QScreen *currentScreen = screen(); - qreal devicePixelRatio = currentScreen ? currentScreen->devicePixelRatio() : 1.0; - - // Resize CEF browser window - RECT rect; - rect.left = 0; - rect.top = 0; - rect.right = width() * devicePixelRatio; - rect.bottom = height() * devicePixelRatio; - - // Use Windows API to resize window - HDWP hdwp = BeginDeferWindowPos(1); - hdwp = DeferWindowPos(hdwp, hwnd, NULL, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, SWP_NOZORDER); - EndDeferWindowPos(hdwp); -#else - // Handle non-Windows platforms - m_client->getBrowser()->GetHost()->WasResized(); -#endif - } - } -} diff --git a/src/17live/QCefView.hpp b/src/17live/QCefView.hpp deleted file mode 100644 index 073b1c1..0000000 --- a/src/17live/QCefView.hpp +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4100 4996) -#else -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif - -#include -#include -#include -#include -#include - -#include -#include -#include - -class SimpleCefClient; - -class QCefView : public QWidget { - Q_OBJECT - - public: - explicit QCefView(QWidget *parent = nullptr); - ~QCefView(); - - // Load URL - void loadUrl(const QString &url); - // Get current URL - QString currentUrl() const; - // Reload current page - void reload(); - - protected: - virtual void resizeEvent(QResizeEvent *event) override; - - private: - CefRefPtr m_client; - - QWindow *m_window; - QWidget *m_container; - QVBoxLayout *m_layout; - QString m_currentUrl; -}; diff --git a/src/17live/SimpleCefClient.cpp b/src/17live/SimpleCefClient.cpp deleted file mode 100644 index b7e3fa1..0000000 --- a/src/17live/SimpleCefClient.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include "SimpleCefClient.hpp" - -#include -#include -#include // For os_event_t, etc. -#include - -#include -#include // For DStr - -#ifdef Q_OS_WIN -#include -#include -#endif - -#include "plugin-support.h" - -void SimpleCefClient::OnAfterCreated(CefRefPtr browser) { - CEF_REQUIRE_UI_THREAD(); - - if (!m_browser) { - m_browser = browser; - } -} - -bool SimpleCefClient::DoClose(CefRefPtr browser) { - CEF_REQUIRE_UI_THREAD(); - - if (m_browser && m_browser->GetIdentifier() == browser->GetIdentifier()) { - m_browser = nullptr; - } - - return false; -} - -void SimpleCefClient::OnBeforeClose(CefRefPtr browser) { - CEF_REQUIRE_UI_THREAD(); - - if (m_browser && m_browser->GetIdentifier() == browser->GetIdentifier()) { - m_browser = nullptr; - } -} diff --git a/src/17live/SimpleCefClient.hpp b/src/17live/SimpleCefClient.hpp deleted file mode 100644 index fad00a6..0000000 --- a/src/17live/SimpleCefClient.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4100 4996) -#else -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif - -#include -#include -#include -#include -#include - -#include -#include -#include - -class SimpleCefClient : public CefClient, public CefLifeSpanHandler { - public: - SimpleCefClient() {} - - CefRefPtr getBrowser() const { - return m_browser; - } - - // CefClient interface implementation - virtual CefRefPtr GetLifeSpanHandler() override { - return this; - } - - // CefLifeSpanHandler interface implementation - virtual void OnAfterCreated(CefRefPtr browser) override; - virtual bool DoClose(CefRefPtr browser) override; - virtual void OnBeforeClose(CefRefPtr browser) override; - - private: - CefRefPtr m_browser; - - // CEF reference counting implementation - IMPLEMENT_REFCOUNTING(SimpleCefClient); -}; diff --git a/src/17live/api/OneSevenLiveAblyChatClient.cpp b/src/17live/api/OneSevenLiveAblyChatClient.cpp new file mode 100644 index 0000000..a52c099 --- /dev/null +++ b/src/17live/api/OneSevenLiveAblyChatClient.cpp @@ -0,0 +1,499 @@ +#include "OneSevenLiveAblyChatClient.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveCoreManager.hpp" +#include "chat/OneSevenLiveChatMessageHandler.hpp" +#include "plugin-support.h" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WebsocketUtils.hpp" +#include "websocket/WsMessage.hpp" + +OneSevenLiveAblyChatClient::OneSevenLiveAblyChatClient(QObject* parent) + : QObject(parent), m_wsClient(std::make_unique()) { + m_hosts = { + "wss://17-media-a-fallback.ably-realtime.com/realtime", + "wss://17-media-b-fallback.ably-realtime.com/realtime", + "wss://17-media-c-fallback.ably-realtime.com/realtime", + }; + + m_wsClient->setOpenCallback([this]() { + if (m_onOpen) + QMetaObject::invokeMethod(this, [this]() { m_onOpen(); }, Qt::QueuedConnection); + m_reconnectAttempts = 0; + }); + m_wsClient->setMessageCallback([this](const std::string& msg) { + // Parse Ably protocol message and attach after CONNECTED + try { + nlohmann::json j = nlohmann::json::parse(msg); + obs_log(LOG_DEBUG, "[Ably] recv %s", j.dump().c_str()); + if (j.contains("action")) { + int action = -1; + if (j["action"].is_number_integer()) + action = j["action"].get(); + else if (j["action"].is_string()) { + // Fallback: map common strings + std::string a = j["action"].get(); + if (a == "connected") + action = 4; + else if (a == "message") + action = 15; + else if (a == "error") + action = 9; + else if (a == "disconnected") + action = 6; + } + if (action == 4) { + try { + if (j.contains("connectionKey") && j["connectionKey"].is_string()) { + m_connectionKey = + QString::fromStdString(j["connectionKey"].get()); + } else if (j.contains("connectionId") && j["connectionId"].is_string()) { + m_connectionKey = + QString::fromStdString(j["connectionId"].get()); + } + } catch (...) { + } + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + QTimer::singleShot(100, this, [this]() { attachChannel(); }); + } else if (action == 11) { + m_attached = true; + } else if (action == 6 || action == 9) { + bool needRefresh = false; + if (j.contains("error") && j["error"].is_object()) { + try { + const auto& e = j["error"]; + if (e.contains("code") && e["code"].is_number_integer()) { + int code = e["code"].get(); + if (code >= 40140 && code <= 40149) + needRefresh = true; + } + } catch (...) { + } + } + if (needRefresh) { + refreshToken([this](bool success) { + (void) success; + scheduleReconnect(); + }); + } else { + scheduleReconnect(); + } + } else if (action == 14) { + } else if (action == 16) { + } else if (action == 12) { + if (j.contains("channel") && j["channel"].is_string()) { + m_attachedChannels.remove( + QString::fromStdString(j["channel"].get())); + } + } else if (action == 1 || action == 2) { + int msgSerial = j.contains("msgSerial") && j["msgSerial"].is_number_integer() + ? j["msgSerial"].get() + : -1; + std::string reason; + if (j.contains("error") && j["error"].is_object()) { + reason = j["error"].dump(); + } + obs_log(LOG_INFO, "[Ably] %s msgSerial=%d reason=%s", + action == 1 ? "ack" : "nack", msgSerial, reason.c_str()); + } + if (j.contains("connectionSerial") && j["connectionSerial"].is_number_integer()) { + try { + m_lastConnectionSerial = j["connectionSerial"].get(); + } catch (...) { + } + } + } + } catch (...) { + } + + OneSevenLiveChatMessageHandler handler; + handler.handleRaw(msg); + if (m_onMessage) + QMetaObject::invokeMethod( + this, [this, msg]() { m_onMessage(msg); }, Qt::QueuedConnection); + }); + m_wsClient->setCloseCallback([this]() { + if (m_onClose) + QMetaObject::invokeMethod(this, [this]() { m_onClose(); }, Qt::QueuedConnection); + if (!m_closing) + scheduleReconnect(); + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventAblyChatConnected), nlohmann::json{{"status", "break"}}); + }); + m_wsClient->setErrorCallback([this](const std::string& err) { + if (m_onError) + QMetaObject::invokeMethod( + this, [this, err]() { m_onError(err); }, Qt::QueuedConnection); + if (m_hostIndex + 1 < (int) m_hosts.size()) { + m_hostIndex++; + tryConnectWithFallbackHosts(); + } else { + scheduleReconnect(); + } + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "break"}, {"error", err}}); + }); +} + +OneSevenLiveAblyChatClient::~OneSevenLiveAblyChatClient() { + disconnect(); +} + +void OneSevenLiveAblyChatClient::setRoomId(const QString& roomId) { + m_roomId = roomId; +} + +void OneSevenLiveAblyChatClient::setAblyToken(const QString& token) { + m_token = token; +} + +void OneSevenLiveAblyChatClient::setOnOpen(const std::function& cb) { + m_onOpen = cb; +} + +void OneSevenLiveAblyChatClient::setOnMessage(const std::function& cb) { + m_onMessage = cb; +} + +void OneSevenLiveAblyChatClient::setOnClose(const std::function& cb) { + m_onClose = cb; +} + +void OneSevenLiveAblyChatClient::setOnError(const std::function& cb) { + m_onError = cb; +} + +void OneSevenLiveAblyChatClient::setAuthCallback( + const std::function& cb) { + m_authCallback = cb; +} + +bool OneSevenLiveAblyChatClient::connect() { + if (m_roomId.isEmpty()) + return false; + + if (m_token.isEmpty()) { + fetchTokenAsync([this](bool success) { + if (success) { + m_hostIndex = 0; + m_closing = false; + m_reconnectAttempts = 0; + cancelReconnect(); + tryConnectWithFallbackHosts(); + } else { + obs_log(LOG_ERROR, "Failed to fetch Ably token during connect"); + } + }); + } else { + scheduleTokenRefresh(0, 0, 0); + m_hostIndex = 0; + m_closing = false; + m_reconnectAttempts = 0; + cancelReconnect(); + tryConnectWithFallbackHosts(); + } + + return true; +} + +void OneSevenLiveAblyChatClient::disconnect() { + m_closing = true; + cancelReconnect(); + cancelTokenRefresh(); + if (m_wsClient) + m_wsClient->disconnect(); +} + +bool OneSevenLiveAblyChatClient::isConnected() const { + return m_wsClient && m_wsClient->isConnected(); +} + +void OneSevenLiveAblyChatClient::tryConnectWithFallbackHosts() { + if (m_hostIndex < 0 || m_hostIndex >= (int) m_hosts.size()) + return; + const QString host = m_hosts[m_hostIndex]; + // Build Ably websocket URL with token auth (JSON protocol, echo off) + QString query = QString("protocol=json&echo=false&access_token=%1&v=1.2").arg(m_token); + if (!m_connectionKey.isEmpty() && m_lastConnectionSerial >= 0) { + query += QString("&resume=%1&connection_serial=%2") + .arg(m_connectionKey, QString::number(m_lastConnectionSerial)); + } + QUrl url(QString("%1?%2").arg(host, query)); + m_wsClient->connectUrl(url.toString()); +} + +void OneSevenLiveAblyChatClient::attachChannel() { + if (!isConnected()) + return; + m_attached = false; + // Attach/subscribe to channel per Ably protocol (JSON protocol message) + // Send ATTACH (numeric action per Ably protocol) + nlohmann::json attachMsg; + attachMsg["action"] = 10; // ATTACH + attachMsg["channel"] = m_roomId.toStdString(); + + // Only log error if send fails or logic fails; success is noisy + // obs_log(LOG_INFO, "[Ably] send %s", attachMsg.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(attachMsg.dump())); +} + +void OneSevenLiveAblyChatClient::attachChannel(const QString& channel) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json attachMsg; + attachMsg["action"] = 10; + attachMsg["channel"] = channel.toStdString(); + // obs_log(LOG_INFO, "[Ably] send %s", attachMsg.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(attachMsg.dump())); +} + +void OneSevenLiveAblyChatClient::detachChannel(const QString& channel) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json detachMsg; + detachMsg["action"] = 12; + detachMsg["channel"] = channel.toStdString(); + obs_log(LOG_INFO, "[Ably] send %s", detachMsg.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(detachMsg.dump())); +} + +void OneSevenLiveAblyChatClient::publishMessage(const QString& channel, + const nlohmann::json& payload) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json pm; + pm["action"] = 15; + pm["channel"] = channel.toStdString(); + pm["msgSerial"] = m_msgSerialCounter++; + nlohmann::json m; + m["data"] = payload; + pm["messages"] = nlohmann::json::array({m}); + obs_log(LOG_INFO, "[Ably] send %s", pm.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(pm.dump())); +} + +void OneSevenLiveAblyChatClient::enterPresence(const QString& channel, const nlohmann::json& data) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json pm; + pm["action"] = 14; + pm["channel"] = channel.toStdString(); + nlohmann::json p; + p["action"] = "enter"; + p["data"] = data; + pm["presence"] = nlohmann::json::array({p}); + obs_log(LOG_INFO, "[Ably] send %s", pm.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(pm.dump())); +} + +void OneSevenLiveAblyChatClient::updatePresence(const QString& channel, + const nlohmann::json& data) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json pm; + pm["action"] = 14; + pm["channel"] = channel.toStdString(); + nlohmann::json p; + p["action"] = "update"; + p["data"] = data; + pm["presence"] = nlohmann::json::array({p}); + obs_log(LOG_INFO, "[Ably] send %s", pm.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(pm.dump())); +} + +void OneSevenLiveAblyChatClient::leavePresence(const QString& channel) { + if (!isConnected() || channel.isEmpty()) + return; + nlohmann::json pm; + pm["action"] = 14; + pm["channel"] = channel.toStdString(); + nlohmann::json p; + p["action"] = "leave"; + pm["presence"] = nlohmann::json::array({p}); + obs_log(LOG_INFO, "[Ably] send %s", pm.dump().c_str()); + m_wsClient->sendText(QString::fromStdString(pm.dump())); +} + +void OneSevenLiveAblyChatClient::scheduleReconnect() { + if (m_closing) + return; + if (!m_reconnectTimer) { + m_reconnectTimer = new QTimer(this); + m_reconnectTimer->setSingleShot(true); + QObject::connect(m_reconnectTimer, &QTimer::timeout, this, [this]() { + if (m_closing) + return; + refreshToken([this](bool success) { + (void) success; + if (m_closing) + return; + m_hostIndex = 0; + tryConnectWithFallbackHosts(); + }); + }); + } + if (m_reconnectAttempts >= m_maxReconnectAttempts) + m_reconnectAttempts = m_maxReconnectAttempts; + int delay = m_baseReconnectDelayMs; + for (int i = 0; i < m_reconnectAttempts; ++i) { + delay = std::min(delay * 2, 15000); + } + m_reconnectAttempts = std::min(m_reconnectAttempts + 1, m_maxReconnectAttempts); + m_reconnectTimer->start(delay); +} + +void OneSevenLiveAblyChatClient::cancelReconnect() { + if (m_reconnectTimer) + m_reconnectTimer->stop(); +} + +void OneSevenLiveAblyChatClient::refreshToken(std::function callback) { + if (m_roomId.isEmpty()) { + if (callback) + callback(false); + return; + } + + fetchTokenAsync(callback); +} + +void OneSevenLiveAblyChatClient::scheduleTokenRefresh(qint64 expiresEpochMs, qint64 issuedEpochMs, + qint64 ttlMs) { + if (!m_tokenRefreshTimer) { + m_tokenRefreshTimer = new QTimer(this); + m_tokenRefreshTimer->setSingleShot(true); + QObject::connect(m_tokenRefreshTimer, &QTimer::timeout, this, [this]() { + if (m_closing) + return; + refreshToken([this](bool success) { + if (m_closing) + return; + if (success) { + if (isConnected()) { + sendAuth(); + } else { + scheduleReconnect(); + } + } + }); + }); + } + qint64 now = QDateTime::currentMSecsSinceEpoch(); + qint64 expiry = 0; + if (expiresEpochMs > 0) + expiry = expiresEpochMs; + else if (issuedEpochMs > 0 && ttlMs > 0) + expiry = issuedEpochMs + ttlMs; + else if (ttlMs > 0) + expiry = now + ttlMs; + else + expiry = now + m_tokenDefaultTtlMs; + m_tokenExpiresMs = expiry; + qint64 msUntilRefresh = std::max(0, expiry - now - m_tokenRefreshAdvanceMs); + m_tokenRefreshTimer->start((int) msUntilRefresh); +} + +void OneSevenLiveAblyChatClient::cancelTokenRefresh() { + if (m_tokenRefreshTimer) + m_tokenRefreshTimer->stop(); +} + +void OneSevenLiveAblyChatClient::sendAuth() { + if (!isConnected()) + return; + nlohmann::json authMsg; + authMsg["action"] = 17; + nlohmann::json auth; + auth["accessToken"] = m_token.toStdString(); + authMsg["auth"] = auth; + m_wsClient->sendText(QString::fromStdString(authMsg.dump())); +} + +void OneSevenLiveAblyChatClient::sendConnect() { + if (!isConnected()) + return; + nlohmann::json conn; + conn["action"] = 2; // CONNECT + if (!m_connectionKey.isEmpty()) { + conn["connectionKey"] = m_connectionKey.toStdString(); + } + conn["protocol"] = "json"; + m_wsClient->sendText(QString::fromStdString(conn.dump())); +} + +void OneSevenLiveAblyChatClient::fetchTokenAsync(std::function callback) { + QString rid = m_roomId; + // Capture required resources by value to ensure thread safety if 'this' is destroyed + // Note: apiWrapper is still a pointer, but capturing it avoids accessing 'this->' inside the + // thread The caller (OneSevenLiveCoreManager) owns apiWrapper, so its lifetime usually exceeds + // this operation + auto authCb = m_authCallback; + auto* api = OneSevenLiveCoreManager::getInstance().getApiWrapper(); + + // Use QPointer to track object validity + QPointer self(this); + + std::thread([self, rid, callback, authCb, api]() { + nlohmann::json resp; + bool success = false; + + if (authCb) { + success = authCb(rid, resp); + } else if (api) { + success = api->GetAblyToken(rid.toStdString(), resp); + } + + // If object is destroyed, don't invoke callback + if (!self) + return; + + QMetaObject::invokeMethod( + self, + [self, success, resp, callback]() { + if (!self) + return; + + if (success) { + if (resp.contains("token") && resp["token"].is_string()) { + self->m_token = QString::fromStdString(resp["token"].get()); + } else { + obs_log(LOG_ERROR, "Got Ably token response error: %s", + resp.dump().c_str()); + } + + if (!self->m_token.isEmpty()) { + qint64 expiresMs = 0, issuedMs = 0, ttlMs = 0; + if (resp.contains("expires") && resp["expires"].is_number_integer()) + expiresMs = resp["expires"].get(); + if (resp.contains("issued") && resp["issued"].is_number_integer()) + issuedMs = resp["issued"].get(); + if (resp.contains("ttl") && resp["ttl"].is_number_integer()) + ttlMs = resp["ttl"].get(); + if (resp.contains("expiresIn") && resp["expiresIn"].is_number_integer()) + ttlMs = resp["expiresIn"].get(); + self->scheduleTokenRefresh(expiresMs, issuedMs, ttlMs); + + if (callback) + callback(true); + return; + } + } + if (callback) + callback(false); + }, + Qt::QueuedConnection); + }).detach(); +} diff --git a/src/17live/api/OneSevenLiveAblyChatClient.hpp b/src/17live/api/OneSevenLiveAblyChatClient.hpp new file mode 100644 index 0000000..6619257 --- /dev/null +++ b/src/17live/api/OneSevenLiveAblyChatClient.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../websocket/OneSevenLiveWebsocketClient.hpp" +#include "OneSevenLiveApiWrappers.hpp" + +class QTimer; + +class OneSevenLiveAblyChatClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveAblyChatClient(QObject* parent = nullptr); + ~OneSevenLiveAblyChatClient(); + + void setRoomId(const QString& roomId); + void setAblyToken(const QString& token); + + void setOnOpen(const std::function& cb); + void setOnMessage(const std::function& cb); + void setOnClose(const std::function& cb); + void setOnError(const std::function& cb); + void setAuthCallback(const std::function& cb); + + bool connect(); + void disconnect(); + bool isConnected() const; + + private: + void tryConnectWithFallbackHosts(); + void attachChannel(); + void attachChannel(const QString& channel); + void detachChannel(const QString& channel); + void publishMessage(const QString& channel, const nlohmann::json& payload); + void enterPresence(const QString& channel, const nlohmann::json& data); + void updatePresence(const QString& channel, const nlohmann::json& data); + void leavePresence(const QString& channel); + void scheduleReconnect(); + void cancelReconnect(); + void refreshToken(std::function callback = nullptr); + void scheduleTokenRefresh(qint64 expiresEpochMs, qint64 issuedEpochMs, qint64 ttlMs); + void cancelTokenRefresh(); + void sendAuth(); + void sendConnect(); + + void fetchTokenAsync(std::function callback); + + QString m_roomId; + QString m_token; + std::unique_ptr m_wsClient; + std::vector m_hosts; + int m_hostIndex = 0; + QTimer* m_reconnectTimer{nullptr}; + int m_reconnectAttempts{0}; + int m_maxReconnectAttempts{10}; + int m_baseReconnectDelayMs{1000}; + bool m_closing{false}; + QTimer* m_tokenRefreshTimer{nullptr}; + qint64 m_tokenExpiresMs{0}; + int m_tokenRefreshAdvanceMs{60000}; + int m_tokenDefaultTtlMs{3000000}; + QString m_connectionKey; + long long m_lastConnectionSerial{-1}; + bool m_attached{false}; + QSet m_attachedChannels; + long long m_msgSerialCounter{0}; + + std::function m_onOpen; + std::function m_onMessage; + std::function m_onClose; + std::function m_onError; + std::function m_authCallback; +}; + +namespace ably { + static constexpr int MsgType_COMMENT = 3; // General comment message + static constexpr int MsgType_NEW_GIFT = 13; // Gift animation message + static constexpr int MsgType_JOIN_ROOM = 18; // Audience join room message + static constexpr int MsgType_NEW_LUCKYBAG = 32; // Random gift message + static constexpr int MsgType_POKE = 47; // Poke message + static constexpr int MsgType_ROCKZONE = 74; // Rock Zone message + static constexpr int MsgType_AI_COHOST_MESSAGE = 120; // AI co-host message +} // namespace ably diff --git a/src/17live/api/OneSevenLiveApiWrappers.cpp b/src/17live/api/OneSevenLiveApiWrappers.cpp index 56b6802..2bdb875 100644 --- a/src/17live/api/OneSevenLiveApiWrappers.cpp +++ b/src/17live/api/OneSevenLiveApiWrappers.cpp @@ -3,7 +3,6 @@ #include #include -#include #include #include @@ -15,6 +14,36 @@ using namespace std; extern const char *service; +// Safe JSON access helper functions +namespace { + // Safe string getter with default value + static std::string safeGetJsonString(const Json &j, const std::string &key, + const std::string &defaultValue = "") { + return j.contains(key) && j[key].is_string() ? j[key].get() : defaultValue; + } + + static int safeGetJsonInt(const Json &j, const std::string &key, int defaultValue = 0) { + return j.contains(key) && j[key].is_number_integer() ? j[key].get() : defaultValue; + } + + // Safe error message handler for JSON responses + static QString buildErrorMessage(const Json &json_resp, + const std::string &defaultError = "Unknown error") { + if (json_resp.is_null() || json_resp.empty()) { + return QString::fromStdString(defaultError); + } + + int errorCode = safeGetJsonInt(json_resp, "errorCode", -1); + + std::string errorMessage = safeGetJsonString(json_resp, "errorMessage", defaultError); + + if (errorCode < 0) { + return QString::fromStdString(std::string("UNKNOWN_ERROR ") + errorMessage); + } + return QString::fromStdString(std::to_string(errorCode) + " " + errorMessage); + } +} // namespace + // Optimized URL constants - avoid repeated string concatenations namespace { const string BASE_API_URL = string(ONESEVENLIVE_API_URL); @@ -78,15 +107,21 @@ const string ONESEVENLIVE_POKE_ALL_URL = buildApiUrl("/api/v1/pokes/pokeAll"); const string ONESEVENLIVE_CHANGE_EVENT_URL = buildApiUrl("/api/v1/liveStreams/event"); OneSevenLiveApiWrappers::OneSevenLiveApiWrappers() : token("") { - currentOS = GetCurrentOS(); - currentOSVersion = GetCurrentOSVersion(); - currentPlatformUUID = GetCurrentPlatformUUID(); + initializeApiWrapper(); } OneSevenLiveApiWrappers::OneSevenLiveApiWrappers(std::string token_) : token(token_) { + initializeApiWrapper(); +} + +void OneSevenLiveApiWrappers::initializeApiWrapper() { currentOS = GetCurrentOS(); currentOSVersion = GetCurrentOSVersion(); currentPlatformUUID = GetCurrentPlatformUUID(); + + obs_log(LOG_INFO, "OneSevenLive API initialized - OS: %s, Version: %s, UUID: %s, Token: %s", + currentOS.c_str(), currentOSVersion.c_str(), currentPlatformUUID.c_str(), + token.empty() ? "Not provided" : "Provided"); } void OneSevenLiveApiWrappers::setLastErrorMessage(const QString &message) { @@ -157,13 +192,22 @@ bool OneSevenLiveApiWrappers::TryInsertCommand(const char *url, const char *cont // Increase timeout by the time it takes to transfer `data_size` at 1 Mbps int timeout = 60 + data_size / 125000; bool success = GetRemoteFile(url, output, error, &httpStatusCode, content_type, request_type, - data, headers, nullptr, timeout, false, data_size); + data, headers, nullptr, timeout, false, data_size, m_cancelFlag); if (error_code) *error_code = httpStatusCode; if (!success || output.empty()) { if (!error.empty()) - obs_log(LOG_WARNING, "17Live API request failed: %s", error.c_str()); + obs_log(LOG_WARNING, "17Live API request failed: %s [url: %s]", error.c_str(), url); + + // Ensure json_out is populated with error information if available + if (!output.empty()) { + try { + json_out = Json::parse(output); + } catch (...) { + // ignore parsing error for failed requests + } + } return false; } @@ -173,7 +217,10 @@ bool OneSevenLiveApiWrappers::TryInsertCommand(const char *url, const char *cont obs_log(LOG_DEBUG, "17Live API command answer: %s", json_out.dump().c_str()); #endif } catch (const Json::parse_error &e) { + // dump error message to stderr obs_log(LOG_ERROR, "Failed to parse JSON response: %s", e.what()); + obs_log(LOG_ERROR, "Response is: %s, status code: %d [url: %s]", output.c_str(), + httpStatusCode, url); return false; } return httpStatusCode < 400; @@ -222,9 +269,7 @@ bool OneSevenLiveApiWrappers::InsertCommand(const char *url, const char *content obs_log(LOG_ERROR, "17Live API error:\n\tHTTP status: %ld\n\tURL: %s\n\tJSON: %s", error_code, url, json_out.dump().c_str()); - const std::string errorCode = json_out["errorCode"].get(); - const std::string errorMessage = json_out["errorMessage"].get(); - setLastErrorMessage(QString::fromStdString(errorCode + " " + errorMessage)); + setLastErrorMessage(buildErrorMessage(json_out, "API request failed")); // The existence of an error implies non-success even if the HTTP status code disagrees. success = false; } @@ -260,6 +305,7 @@ bool OneSevenLiveApiWrappers::Login(const QString &username, const QString &pass Json json_out; if (!InsertCommand(url, "application/json", "", postData.c_str(), json_out, 0, false)) { + setLastErrorMessage(buildErrorMessage(json_out, "Login failed")); // Login failed display json_out obs_log(LOG_ERROR, "Login failed: %s", json_out.dump().c_str()); return false; @@ -275,8 +321,7 @@ bool OneSevenLiveApiWrappers::Login(const QString &username, const QString &pass // check if json_out_data contains "result" key if (json_out_data.contains("result")) { if (json_out_data["result"].get() == "fail") { - const std::string messageStr = json_out_data["message"].get(); - setLastErrorMessage(QString::fromStdString(messageStr)); + setLastErrorMessage(buildErrorMessage(json_out_data, "Login failed")); return false; } } else { @@ -331,7 +376,7 @@ bool OneSevenLiveApiWrappers::OneSevenLiveApiWrappers::GetSelfInfo( if (!json_out.contains("openID")) { obs_log(LOG_ERROR, "GetSelfInfo response missing openID field: %s", json_out.dump().c_str()); - lastErrorMessage = "GetSelfInfo response missing openID field"; + setLastErrorMessage("GetSelfInfo response missing openID field"); return false; } @@ -360,7 +405,7 @@ bool OneSevenLiveApiWrappers::ChangeEvent(const OneSevenLiveChangeEventRequest & Json requestData; if (!OneSevenLiveChangeEventRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } @@ -373,10 +418,7 @@ bool OneSevenLiveApiWrappers::ChangeEvent(const OneSevenLiveChangeEventRequest & if (!InsertCommand(url.constData(), "application/json", "POST", postData.c_str(), json_out)) { obs_log(LOG_ERROR, "ChangeEvent error: %s", json_out.dump().c_str()); // Pre-convert error strings to avoid repeated conversions - const std::string errorCodeStr = json_out["errorCode"].get(); - const std::string errorMessageStr = json_out["errorMessage"].get(); - lastErrorMessage = - QString::fromStdString(errorCodeStr) + " " + QString::fromStdString(errorMessageStr); + setLastErrorMessage(buildErrorMessage(json_out, "ChangeEvent failed")); return false; } @@ -386,9 +428,7 @@ bool OneSevenLiveApiWrappers::ChangeEvent(const OneSevenLiveChangeEventRequest & // Check if errorCode field exists if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "ChangeEvent error: %s", json_out.dump().c_str()); - // lastErrorMessage = errorCode + errorMessage - lastErrorMessage = QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out, "ChangeEvent failed")); return false; } @@ -424,16 +464,19 @@ bool OneSevenLiveApiWrappers::CommonRequest(const std::string action, Json &json // Check if exist errorCode field if (json_out_resp.contains("errorCode")) { obs_log(LOG_ERROR, "apiGateWay error: %s", json_out_resp.dump().c_str()); - // lastErrorMessage = errorCode + errorMessage - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "CommonRequest failed")); return false; } // transform string json_out["data"] to Json try { - json_out = Json::parse(json_out_resp["data"].get()); + std::string dataStr = safeGetJsonString(json_out_resp, "data", "{}"); + if (dataStr.empty()) { + obs_log(LOG_ERROR, "apiGateWay response missing data field"); + setLastErrorMessage("CommonRequest failed: missing data field"); + return false; + } + json_out = Json::parse(dataStr); } catch (const Json::parse_error &e) { obs_log(LOG_ERROR, "Failed to parse apiGateWay response data: %s", e.what()); return false; @@ -452,18 +495,14 @@ bool OneSevenLiveApiWrappers::GetRoomInfo(const qint64 roomID, OneSevenLiveRoomI Json json_out; if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out, 0, true)) { obs_log(LOG_ERROR, "GetRoomInfo failed %s", json_out.dump().c_str()); - lastErrorMessage = QString::fromStdString("GetRoomInfo failed %s") - .arg(json_out.dump().c_str()) - .toUtf8() - .constData(); - + setLastErrorMessage(buildErrorMessage(json_out, "GetRoomInfo failed")); return false; } // Use JsonToOneSevenLiveRoomInfo function to parse data to struct if (!JsonToOneSevenLiveRoomInfo(json_out, roomInfo)) { obs_log(LOG_ERROR, "Failed to parse room info data"); - lastErrorMessage = "Failed to parse room info data"; + setLastErrorMessage("Failed to parse room info data"); return false; } @@ -493,7 +532,7 @@ bool OneSevenLiveApiWrappers::CreateRtmp(const OneSevenLiveRtmpRequest &request, Json requestData; if (!OneSevenLiveRtmpRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } @@ -505,6 +544,19 @@ bool OneSevenLiveApiWrappers::CreateRtmp(const OneSevenLiveRtmpRequest &request, Json json_out; if (!InsertCommand(url, "application/json", "", postData.c_str(), json_out)) { + if (json_out.contains("errorCode")) { + obs_log(LOG_ERROR, "CreateRtmp error: %s", json_out.dump().c_str()); + int errorCode = safeGetJsonInt(json_out, "errorCode", -1); + if (errorCode == 39) { + setLastErrorMessage(QString::fromStdString(obs_module_text("Api.Error.39"))); + } else if (errorCode == 35) { + setLastErrorMessage(QString::fromStdString(obs_module_text("Api.Error.35"))); + } else { + setLastErrorMessage(QString::fromStdString(obs_module_text("Api.Error.Generic")) + .arg(buildErrorMessage(json_out, "CreateRtmp failed"))); + } + } + return false; } @@ -514,15 +566,13 @@ bool OneSevenLiveApiWrappers::CreateRtmp(const OneSevenLiveRtmpRequest &request, // Check if exist errorCode field if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "CreateRtmp error: %s", json_out.dump().c_str()); - // lastErrorMessage = errorCode + errorMessage - lastErrorMessage = QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out, "CreateRtmp failed")); return false; } if (!JsonToOneSevenLiveRtmpResponse(json_out, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -547,9 +597,7 @@ bool OneSevenLiveApiWrappers::StartStream(const std::string &liveStreamID, if (!InsertCommand(url.constData(), "application/json", "PATCH", postData.c_str(), json_out_resp)) { obs_log(LOG_ERROR, "StartStream error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "StartStream failed")); return false; } @@ -570,9 +618,7 @@ bool OneSevenLiveApiWrappers::EnableStreamArchive(const std::string &liveStreamI // null post data, explicitly set request type as POST if (!InsertCommand(url.constData(), "application/json", "POST", nullptr, json_out_resp)) { obs_log(LOG_ERROR, "EnableStreamArchive error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "EnableStreamArchive failed")); return false; } obs_log(LOG_INFO, "EnableStreamArchive success"); @@ -589,7 +635,7 @@ bool OneSevenLiveApiWrappers::StopStream(const std::string &liveStreamID, Json requestData; if (!OneSevenLiveCloseLiveRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } std::string postData = requestData.dump(); @@ -599,9 +645,7 @@ bool OneSevenLiveApiWrappers::StopStream(const std::string &liveStreamID, if (!InsertCommand(url.constData(), "application/json", "DELETE", postData.c_str(), json_out_resp)) { obs_log(LOG_ERROR, "StopStream error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "StopStream failed")); return false; } @@ -642,11 +686,7 @@ bool OneSevenLiveApiWrappers::CreateCustomEvent(const OneSevenLiveCustomEvent &r // Check if errorCode field exists if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "CreateCustomEvent error: %s", json_out.dump().c_str()); - // Pre-convert error strings to avoid repeated conversions - const std::string errorCodeStr = json_out["errorCode"].get(); - const std::string errorMessageStr = json_out["errorMessage"].get(); - setLastErrorMessage(QString::fromStdString(errorCodeStr) + " " + - QString::fromStdString(errorMessageStr)); + setLastErrorMessage(buildErrorMessage(json_out, "CreateCustomEvent failed")); return false; } @@ -683,7 +723,7 @@ bool OneSevenLiveApiWrappers::ChangeCustomEventStatus( Json requestData; if (!OneSevenLiveChangeCustomEventStatusRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } @@ -697,10 +737,7 @@ bool OneSevenLiveApiWrappers::ChangeCustomEventStatus( // Check if errorCode field exists if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "ChangeCustomEventStatus error: %s", json_out.dump().c_str()); - // lastErrorMessage = errorCode + errorMessage - setLastErrorMessage( - QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get())); + setLastErrorMessage(buildErrorMessage(json_out, "ChangeCustomEventStatus failed")); } return false; } @@ -721,9 +758,7 @@ bool OneSevenLiveApiWrappers::CheckStream(const std::string &liveStreamID) { Json json_out_resp; if (!InsertCommand(url.constData(), "application/json", "POST", nullptr, json_out_resp)) { obs_log(LOG_ERROR, "CheckStream error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "CheckStream failed")); return false; } @@ -736,7 +771,7 @@ bool OneSevenLiveApiWrappers::GetConfigStreamer(const std::string region, OneSevenLiveConfigStreamer &response) { obs_log(LOG_INFO, "GetConfigStreamer"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_CONFIG_STREAMER_URL); QByteArray url = urlStr.toUtf8(); @@ -748,15 +783,13 @@ bool OneSevenLiveApiWrappers::GetConfigStreamer(const std::string region, if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetConfigStreamer error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetConfigStreamer failed")); return false; } if (!JsonToOneSevenLiveConfigStreamer(json_out_resp, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -768,7 +801,7 @@ bool OneSevenLiveApiWrappers::GetRtmpByProvider(const std::string provider, OneSevenLiveRtmpResponse &response) { obs_log(LOG_INFO, "GetRtmpByProvider"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_RTMP_URL).arg(provider.c_str()); QByteArray url = urlStr.toUtf8(); @@ -776,15 +809,13 @@ bool OneSevenLiveApiWrappers::GetRtmpByProvider(const std::string provider, Json json_out_resp; if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp)) { obs_log(LOG_ERROR, "GetRtmpByProvider error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetRtmpByProvider failed")); return false; } if (!JsonToOneSevenLiveRtmpResponse(json_out_resp, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -796,7 +827,7 @@ bool OneSevenLiveApiWrappers::GetArmySubscriptionLevels( OneSevenLiveArmySubscriptionLevels &response) { obs_log(LOG_INFO, "GetArmySubscriptionLevels"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_ARMYSUBSCRIPIONLEVELS_URL); QByteArray url = urlStr.toUtf8(); @@ -808,15 +839,13 @@ bool OneSevenLiveApiWrappers::GetArmySubscriptionLevels( if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetArmySubscriptionLevels error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetArmySubscriptionLevels failed")); return false; } if (!JsonToOneSevenLiveArmySubscriptionLevels(json_out_resp, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -828,7 +857,7 @@ bool OneSevenLiveApiWrappers::GetConfig(const std::string region, const std::str Json &json_out_resp) { obs_log(LOG_INFO, "GetConfig"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_CONFIG_URL); QByteArray url = urlStr.toUtf8(); @@ -840,9 +869,7 @@ bool OneSevenLiveApiWrappers::GetConfig(const std::string region, const std::str if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetConfig error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetConfig failed")); return false; } @@ -854,7 +881,7 @@ bool OneSevenLiveApiWrappers::GetUserInfo(const std::string userID, const std::s OneSevenLiveUserInfo &response) { obs_log(LOG_INFO, "GetUserInfo"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_USERINFO_URL).arg(userID.c_str()); QByteArray url = urlStr.toUtf8(); @@ -866,15 +893,13 @@ bool OneSevenLiveApiWrappers::GetUserInfo(const std::string userID, const std::s if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetUserInfo error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetUserInfo failed")); return false; } if (!JsonToOneSevenLiveUserInfo(json_out_resp, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -884,15 +909,14 @@ bool OneSevenLiveApiWrappers::GetUserInfo(const std::string userID, const std::s bool OneSevenLiveApiWrappers::GetAblyToken(const std::string &liveStreamID, Json &json_out) { // obs_log(LOG_INFO, "GetAblyToken"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_ABLY_TOKEN_URL).arg(liveStreamID.c_str()); QByteArray url = urlStr.toUtf8(); if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out, 0, true)) { obs_log(LOG_ERROR, "GetAblyToken error: %s", json_out.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out, "GetAblyToken failed")); return false; } @@ -905,7 +929,7 @@ bool OneSevenLiveApiWrappers::GetGiftTabs(const std::string &roomID, const std:: Json &json_out_resp) { obs_log(LOG_INFO, "GetGiftTabs"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_GIFTTABS_URL).arg(roomID.c_str()); QByteArray url = urlStr.toUtf8(); @@ -915,9 +939,7 @@ bool OneSevenLiveApiWrappers::GetGiftTabs(const std::string &roomID, const std:: if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetConfigStreamer error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetGiftTabs failed")); return false; } @@ -927,7 +949,7 @@ bool OneSevenLiveApiWrappers::GetGiftTabs(const std::string &roomID, const std:: bool OneSevenLiveApiWrappers::GetGifts(const std::string language, Json &json_out_resp) { obs_log(LOG_INFO, "GetGifts: %s", language.c_str()); - lastErrorMessage.clear(); + clearLastErrorMessage(); QByteArray url = ONESEVENLIVE_GET_GIFTS_URL.c_str(); @@ -936,9 +958,7 @@ bool OneSevenLiveApiWrappers::GetGifts(const std::string language, Json &json_ou if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true, extraHeaders)) { obs_log(LOG_ERROR, "GetGifts error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetGifts failed")); return false; } @@ -950,16 +970,14 @@ bool OneSevenLiveApiWrappers::GetGifts(const std::string language, Json &json_ou bool OneSevenLiveApiWrappers::GetRockViewers(const std::string &roomID, Json &json_out_resp) { // obs_log(LOG_INFO, "GetRockViewers"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_ROCKVIEWERS_URL).arg(roomID.c_str()); QByteArray url = urlStr.toUtf8(); if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp, 0, true)) { obs_log(LOG_ERROR, "GetRockViewers error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetRockViewers failed")); return false; } @@ -971,7 +989,7 @@ bool OneSevenLiveApiWrappers::GetCustomEvent(const std::string &userID, OneSevenLiveCustomEvent &response) { obs_log(LOG_INFO, "GetCustomEvent start"); - lastErrorMessage.clear(); + clearLastErrorMessage(); // Build request URL with query parameter QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_CUSTOMEVENT_URL) + @@ -981,17 +999,14 @@ bool OneSevenLiveApiWrappers::GetCustomEvent(const std::string &userID, Json json_out; if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out, 0, true)) { obs_log(LOG_ERROR, "GetCustomEvent failed %s", json_out.dump().c_str()); - lastErrorMessage = QString::fromStdString("GetCustomEvent failed %s") - .arg(json_out.dump().c_str()) - .toUtf8() - .constData(); + setLastErrorMessage(buildErrorMessage(json_out, "GetCustomEvent failed")); return false; } // Use JsonToOneSevenLiveCustomEvent function to parse data to struct if (!JsonToOneSevenLiveCustomEvent(json_out, response)) { obs_log(LOG_ERROR, "Failed to parse custom event data"); - lastErrorMessage = "Failed to parse custom event data"; + setLastErrorMessage("Failed to parse custom event data"); return false; } @@ -1002,7 +1017,7 @@ bool OneSevenLiveApiWrappers::GetCustomEvent(const std::string &userID, bool OneSevenLiveApiWrappers::GetArmyName(const std::string &userID, OneSevenLiveArmyNameResponse &response) { obs_log(LOG_INFO, "GetArmyName start"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QString urlStr = QString::fromStdString(ONESEVENLIVE_GET_ARMYNAME_URL).arg(userID.c_str()); QByteArray url = urlStr.toUtf8(); @@ -1011,15 +1026,13 @@ bool OneSevenLiveApiWrappers::GetArmyName(const std::string &userID, if (!InsertCommand(url.constData(), "application/json", "GET", nullptr, json_out_resp)) { obs_log(LOG_ERROR, "GetArmyName error: %s", json_out_resp.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out_resp["errorCode"].get()) + - " " + - QString::fromStdString(json_out_resp["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out_resp, "GetArmyName failed")); return false; } if (!JsonToOneSevenLiveArmyNameResponse(json_out_resp, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -1031,7 +1044,7 @@ bool OneSevenLiveApiWrappers::PokeOne(const OneSevenLivePokeRequest &request, OneSevenLivePokeResponse &response) { obs_log(LOG_INFO, "PokeOne start"); - lastErrorMessage.clear(); + clearLastErrorMessage(); QByteArray url = QByteArray(ONESEVENLIVE_POKE_URL.c_str()); obs_log(LOG_INFO, "PokeOne url: %s", ONESEVENLIVE_POKE_URL.c_str()); @@ -1039,7 +1052,7 @@ bool OneSevenLiveApiWrappers::PokeOne(const OneSevenLivePokeRequest &request, Json requestData; if (!OneSevenLivePokeRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } @@ -1051,8 +1064,7 @@ bool OneSevenLiveApiWrappers::PokeOne(const OneSevenLivePokeRequest &request, if (!InsertCommand(url.constData(), "application/json", "POST", postData.c_str(), json_out)) { obs_log(LOG_ERROR, "PokeOne error: %s", json_out.dump().c_str()); - lastErrorMessage = QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out, "PokeOne failed")); return false; } @@ -1062,15 +1074,13 @@ bool OneSevenLiveApiWrappers::PokeOne(const OneSevenLivePokeRequest &request, // Check if errorCode field exists if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "PokeOne error: %s", json_out.dump().c_str()); - // lastErrorMessage = errorCode + errorMessage - lastErrorMessage = QString::fromStdString(json_out["errorCode"].get()) + " " + - QString::fromStdString(json_out["errorMessage"].get()); + setLastErrorMessage(buildErrorMessage(json_out, "PokeOne failed")); return false; } if (!JsonToOneSevenLivePokeResponse(json_out, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } @@ -1089,7 +1099,7 @@ bool OneSevenLiveApiWrappers::PokeAll(const OneSevenLivePokeAllRequest &request, Json requestData; if (!OneSevenLivePokeAllRequestToJson(request, requestData)) { obs_log(LOG_ERROR, "Failed to convert request to JSON"); - lastErrorMessage = "Failed to convert request to JSON"; + setLastErrorMessage("Failed to convert request to JSON"); return false; } @@ -1101,11 +1111,7 @@ bool OneSevenLiveApiWrappers::PokeAll(const OneSevenLivePokeAllRequest &request, if (!InsertCommand(url.constData(), "application/json", "POST", postData.c_str(), json_out)) { obs_log(LOG_ERROR, "PokeAll error: %s", json_out.dump().c_str()); - // Pre-convert error strings to avoid repeated conversions - const std::string errorCodeStr = json_out["errorCode"].get(); - const std::string errorMessageStr = json_out["errorMessage"].get(); - lastErrorMessage = - QString::fromStdString(errorCodeStr) + " " + QString::fromStdString(errorMessageStr); + setLastErrorMessage(buildErrorMessage(json_out, "PokeAll failed")); return false; } @@ -1115,17 +1121,21 @@ bool OneSevenLiveApiWrappers::PokeAll(const OneSevenLivePokeAllRequest &request, // Check if errorCode field exists if (json_out.contains("errorCode")) { obs_log(LOG_ERROR, "PokeAll error: %s", json_out.dump().c_str()); - // Pre-convert error strings to avoid repeated conversions - const std::string errorCodeStr = json_out["errorCode"].get(); - const std::string errorMessageStr = json_out["errorMessage"].get(); - lastErrorMessage = - QString::fromStdString(errorCodeStr) + " " + QString::fromStdString(errorMessageStr); + int errorCode = safeGetJsonInt(json_out, "errorCode", -1); + std::string errorMessageStr = safeGetJsonString(json_out, "errorMessage", ""); + if (errorCode < 0) { + setLastErrorMessage( + QString::fromStdString(std::string("UNKNOWN_ERROR ") + errorMessageStr)); + } else { + setLastErrorMessage(QString::number(errorCode) + " " + + QString::fromStdString(errorMessageStr)); + } return false; } if (!JsonToOneSevenLivePokeResponse(json_out, response)) { obs_log(LOG_ERROR, "Failed to convert response to struct"); - lastErrorMessage = "Failed to convert response to struct"; + setLastErrorMessage("Failed to convert response to struct"); return false; } diff --git a/src/17live/api/OneSevenLiveApiWrappers.hpp b/src/17live/api/OneSevenLiveApiWrappers.hpp index 456a13b..273ec2e 100644 --- a/src/17live/api/OneSevenLiveApiWrappers.hpp +++ b/src/17live/api/OneSevenLiveApiWrappers.hpp @@ -20,6 +20,7 @@ #define ACTION_GETABLYTOKEN "getAblyToken" #define ACTION_GETGIFTTABS "getGiftTabs" #define ACTION_GETGIFTS "getGifts" +#define ACTION_GETGIFT "getGift" #define ACTION_GETROOMINFO "getRoomInfo" #define MAX_CONSECUTIVE_FAILURES 10 // Maximum consecutive failure count @@ -126,12 +127,21 @@ class OneSevenLiveApiWrappers : public QObject { return token; } + /** + * @brief Set the cancel flag for network requests + * @param flag Pointer to atomic bool flag + */ + void setCancelFlag(std::atomic *flag) { + m_cancelFlag = flag; + } + protected: std::string refresh_token; std::string token; bool implicit = false; uint64_t expire_time = 0; int currentScopeVer = 0; + std::atomic *m_cancelFlag = nullptr; private: QString lastErrorMessage; @@ -146,4 +156,5 @@ class OneSevenLiveApiWrappers : public QObject { // Thread-safe helper methods for error message management void setLastErrorMessage(const QString &message); void clearLastErrorMessage(); + void initializeApiWrapper(); }; diff --git a/src/17live/api/OneSevenLiveModels.cpp b/src/17live/api/OneSevenLiveModels.cpp index b0d55fa..12b006d 100644 --- a/src/17live/api/OneSevenLiveModels.cpp +++ b/src/17live/api/OneSevenLiveModels.cpp @@ -121,8 +121,9 @@ bool JsonToOneSevenLiveLoginData(const Json &json, OneSevenLiveLoginData &loginD if (userInfoJson.contains("lastUsedHashtags") && userInfoJson["lastUsedHashtags"].is_array()) { - // Parse last used hashtags - JsonToOneSevenLiveHashtags(userInfoJson["lastUsedHashtags"], loginData.userInfo.lastUsedHashtags); + // Parse last used hashtags + JsonToOneSevenLiveHashtags(userInfoJson["lastUsedHashtags"], + loginData.userInfo.lastUsedHashtags); } if (userInfoJson.contains("levelBadges") && userInfoJson["levelBadges"].is_array()) { @@ -135,7 +136,8 @@ bool JsonToOneSevenLiveLoginData(const Json &json, OneSevenLiveLoginData &loginD // Object attributes - monthlyVIPBadges // Note: This assumes QVariantMap can be built directly from JSON object, actual // implementation may need adjustment - if (userInfoJson["monthlyVIPBadges"].is_object()) { + if (userInfoJson.contains("monthlyVIPBadges") && + userInfoJson["monthlyVIPBadges"].is_object()) { // Need to handle monthlyVIPBadges based on actual situation // Simple example: // const auto& badges = userInfoJson["monthlyVIPBadges"].object_items(); @@ -425,7 +427,7 @@ bool JsonToOneSevenLiveArmyInfo(const nlohmann::json &json, OneSevenLiveArmyInfo return false; } - if (json["user"].is_object()) { + if (json.contains("user") && json["user"].is_object()) { JsonToOneSevenLiveArmyInfoUser(json["user"], armyInfo.user); } @@ -522,7 +524,7 @@ bool JsonToOneSevenLiveUserAttr(const nlohmann::json &json, OneSevenLiveUserAttr userAttr.gloryroadMode = json["gloryroadMode"].get(); } - if (json["gloryroadInfo"].is_object()) { + if (json.contains("gloryroadInfo") && json["gloryroadInfo"].is_object()) { JsonToOneSevenLiveGloryroadInfo(json["gloryroadInfo"], userAttr.gloryroadInfo); } @@ -619,7 +621,7 @@ bool JsonToOneSevenLiveDisplayUser(const nlohmann::json &json, displayUser.fgColor = QString::fromStdString(json["fgColor"].get()); } - if (json["gloryroadInfo"].is_object()) { + if (json.contains("gloryroadInfo") && json["gloryroadInfo"].is_object()) { JsonToOneSevenLiveGloryroadInfo(json["gloryroadInfo"], displayUser.gloryroadInfo); } @@ -772,6 +774,73 @@ bool OneSevenLiveGiftRankOneToJson(const OneSevenLiveGiftRankOne &giftRankOne, return true; } +bool JsonToOneSevenLiveGuardianOwner(const nlohmann::json &json, OneSevenLiveGuardianOwner &owner) { + if (json.contains("userID") && json["userID"].is_string()) { + owner.userID = QString::fromStdString(json["userID"].get()); + } + if (json.contains("displayName") && json["displayName"].is_string()) { + owner.displayName = QString::fromStdString(json["displayName"].get()); + } + if (json.contains("picture") && json["picture"].is_string()) { + owner.picture = QString::fromStdString(json["picture"].get()); + } + if (json.contains("name") && json["name"].is_string()) { + owner.name = QString::fromStdString(json["name"].get()); + } + if (json.contains("level") && json["level"].is_number()) { + owner.level = json["level"].get(); + } + if (json.contains("openID") && json["openID"].is_string()) { + owner.openID = QString::fromStdString(json["openID"].get()); + } + if (json.contains("region") && json["region"].is_string()) { + owner.region = QString::fromStdString(json["region"].get()); + } + if (json.contains("gloryroadMode") && json["gloryroadMode"].is_number()) { + owner.gloryroadMode = json["gloryroadMode"].get(); + } + return true; +} + +bool OneSevenLiveGuardianOwnerToJson(const OneSevenLiveGuardianOwner &owner, nlohmann::json &json) { + json = { + {"userID", owner.userID.toStdString()}, + {"displayName", owner.displayName.toStdString()}, + {"picture", owner.picture.toStdString()}, + {"name", owner.name.toStdString()}, + {"level", owner.level}, + {"openID", owner.openID.toStdString()}, + {"region", owner.region.toStdString()}, + {"gloryroadMode", owner.gloryroadMode}, + }; + return true; +} + +bool JsonToOneSevenLiveGuardian(const nlohmann::json &json, OneSevenLiveGuardian &guardian) { + if (json.contains("owner") && json["owner"].is_object()) { + JsonToOneSevenLiveGuardianOwner(json["owner"], guardian.owner); + } + if (json.contains("bidPrice") && json["bidPrice"].is_number()) { + guardian.bidPrice = json["bidPrice"].get(); + } + if (json.contains("expireTime") && json["expireTime"].is_number()) { + guardian.expireTime = json["expireTime"].get(); + } + return true; +} + +bool OneSevenLiveGuardianToJson(const OneSevenLiveGuardian &guardian, nlohmann::json &json) { + nlohmann::json ownerJson; + OneSevenLiveGuardianOwnerToJson(guardian.owner, ownerJson); + + json = { + {"owner", ownerJson}, + {"bidPrice", guardian.bidPrice}, + {"expireTime", static_cast(guardian.expireTime)}, + }; + return true; +} + // Convert JSON to OneSevenLiveRockZoneViewer bool JsonToOneSevenLiveRockZoneViewer(const nlohmann::json &json, OneSevenLiveRockZoneViewer &viewer) { @@ -811,6 +880,10 @@ bool JsonToOneSevenLiveRockZoneViewer(const nlohmann::json &json, JsonToOneSevenLiveGiftRankOne(json["giftRankOne"], viewer.giftRankOne); } + if (json.contains("guardian") && json["guardian"].is_object()) { + JsonToOneSevenLiveGuardian(json["guardian"], viewer.guardian); + } + return true; } @@ -835,6 +908,9 @@ bool OneSevenLiveRockZoneViewerToJson(const OneSevenLiveRockZoneViewer &viewer, nlohmann::json giftRankOneJson; OneSevenLiveGiftRankOneToJson(viewer.giftRankOne, giftRankOneJson); + nlohmann::json guardianJson; + OneSevenLiveGuardianToJson(viewer.guardian, guardianJson); + json = nlohmann::json{ {"type", viewer.type}, {"armyInfo", armyInfoJson}, @@ -844,6 +920,7 @@ bool OneSevenLiveRockZoneViewerToJson(const OneSevenLiveRockZoneViewer &viewer, {"armyLevel", viewer.armyLevel}, {"displayUser", displayUserJson}, {"giftRankOne", giftRankOneJson}, + {"guardian", guardianJson}, }; return true; @@ -1789,7 +1866,7 @@ bool JsonToOneSevenLiveConfigStreamer(const nlohmann::json &json, try { // Parse event section - if (json["event"].is_object()) { + if (json.contains("event") && json["event"].is_object()) { JsonToOneSevenLiveEventSection(json["event"], response.event); } @@ -2834,3 +2911,105 @@ bool OneSevenLivePokeAllRequestToJson(const OneSevenLivePokeAllRequest &request, return true; } + +// Sort viewers according to priority rules +QList SortOneSevenLiveRockZoneViewers( + QList &viewers) { + std::sort(viewers.begin(), viewers.end(), + [](const OneSevenLiveRockZoneViewer &a, const OneSevenLiveRockZoneViewer &b) { + // Priority 1: Army viewers (type = 3) - sort by rank (special rule: rank 5 is + // lowest, 1-4 from high to low) + if (a.type == 3 && b.type == 3) { + // Special handling: rank 5 is the lowest rank, should be at the end + if (a.armyInfo.rank == 5 && b.armyInfo.rank != 5) { + return false; // rank 5 goes to the end + } + if (a.armyInfo.rank != 5 && b.armyInfo.rank == 5) { + return true; // rank 5 goes to the end + } + // For ranks 1-4, higher rank number comes first (4 > 3 > 2 > 1) + if (a.armyInfo.rank != b.armyInfo.rank) { + return a.armyInfo.rank > b.armyInfo.rank; + } + } else if (a.type == 3 && b.type != 3) { + return true; // Army viewers have highest priority + } else if (a.type != 3 && b.type == 3) { + return false; + } + + // Priority 2: Guardian Knights (type = 2) + if (a.type == 2 && b.type == 2) { + // Same priority, fall through to next check + } else if (a.type == 2 && b.type != 2) { + return true; + } else if (a.type != 2 && b.type == 2) { + return false; + } + + // Priority 3: Top gifters this session (type = 1) + if (a.type == 1 && b.type == 1) { + // Same priority, fall through to next check + } else if (a.type == 1 && b.type != 1) { + return true; + } else if (a.type != 1 && b.type == 1) { + return false; + } + + // Priority 4: VIP members - sort by VIP level (higher level first) + bool aIsVIP = a.displayUser.isVIP; + bool bIsVIP = b.displayUser.isVIP; + if (aIsVIP && bIsVIP) { + if (a.displayUser.mLevel != b.displayUser.mLevel) { + return a.displayUser.mLevel > b.displayUser.mLevel; + } + } else if (aIsVIP && !bIsVIP) { + return true; + } else if (!aIsVIP && bIsVIP) { + return false; + } + + // Priority 5: Glory road users - sort by glory level (higher level first) + int aGloryLevel = a.userAttr.gloryroadInfo.level; + int bGloryLevel = b.userAttr.gloryroadInfo.level; + if (aGloryLevel > 0 && bGloryLevel > 0) { + if (aGloryLevel != bGloryLevel) { + return aGloryLevel > bGloryLevel; + } + } else if (aGloryLevel > 0 && bGloryLevel <= 0) { + return true; + } else if (aGloryLevel <= 0 && bGloryLevel > 0) { + return false; + } + + // Priority 6: Check-in users - sort by check-in level (higher level first) + int aCheckinLevel = a.displayUser.checkinLevel; + int bCheckinLevel = b.displayUser.checkinLevel; + if (aCheckinLevel > 0 && bCheckinLevel > 0) { + if (aCheckinLevel != bCheckinLevel) { + return aCheckinLevel > bCheckinLevel; + } + } else if (aCheckinLevel > 0 && bCheckinLevel <= 0) { + return true; + } else if (aCheckinLevel <= 0 && bCheckinLevel > 0) { + return false; + } + + // Priority 7: Other gifters - sort by sent points (higher points first) + int aSentPoint = a.userAttr.sentPoint; + int bSentPoint = b.userAttr.sentPoint; + if (aSentPoint > 0 && bSentPoint > 0) { + if (aSentPoint != bSentPoint) { + return aSentPoint > bSentPoint; + } + } else if (aSentPoint > 0 && bSentPoint <= 0) { + return true; + } else if (aSentPoint <= 0 && bSentPoint > 0) { + return false; + } + + // Priority 8: Other users - sort by user level (higher level first) + return a.userAttr.level > b.userAttr.level; + }); + + return viewers; +} diff --git a/src/17live/api/OneSevenLiveModels.hpp b/src/17live/api/OneSevenLiveModels.hpp index e380d17..87592ef 100644 --- a/src/17live/api/OneSevenLiveModels.hpp +++ b/src/17live/api/OneSevenLiveModels.hpp @@ -72,13 +72,13 @@ struct OneSevenLiveAPIResult { }; struct OneSevenLiveOnliveInfo { - int premiumType; + int premiumType = 0; }; // hashtag struct struct OneSevenLiveHashtag { QString text; - bool isOfficial; + bool isOfficial = false; }; struct OneSevenLiveUserInfo { @@ -89,54 +89,54 @@ struct OneSevenLiveUserInfo { QString bio; QString picture; QString website; - int followerCount; - int followingCount; - int receivedLikeCount; - int likeCount; - int isFollowing; - int isNotif; - int isBlocked; - qint64 followTime; - qint64 followRequestTime; - qint64 roomID; + int followerCount = 0; + int followingCount = 0; + int receivedLikeCount = 0; + int likeCount = 0; + int isFollowing = 0; + int isNotif = 0; + int isBlocked = 0; + qint64 followTime = 0; + qint64 followRequestTime = 0; + qint64 roomID = 0; QString privacyMode; - int ballerLevel; - int postCount; - int isCelebrity; - int baller; - int level; - int followPrivacyMode; + int ballerLevel = 0; + int postCount = 0; + int isCelebrity = 0; + int baller = 0; + int level = 0; + int followPrivacyMode = 0; QString revenueShareIndicator; - int clanStatus; + int clanStatus = 0; QStringList badgeInfo; QString region; - int hideAllPointToLeaderboard; - int enableShop; + int hideAllPointToLeaderboard = 0; + int enableShop = 0; QVariantMap monthlyVIPBadges; - qint64 lastLiveTimestamp; - qint64 lastCreateLiveTimestamp; + qint64 lastLiveTimestamp = 0; + qint64 lastCreateLiveTimestamp = 0; QString lastLiveRegion; QStringList loyaltyInfo; - bool streamerRecapEnable; - int gloryroadMode; + bool streamerRecapEnable = false; + int gloryroadMode = 0; QList lastUsedHashtags; - bool newbieDisplayAllGiftTabsToast; - int avatarOnboardingPhase; - bool isUnderaged; + bool newbieDisplayAllGiftTabsToast = false; + int avatarOnboardingPhase = 0; + bool isUnderaged = false; QStringList levelBadges; - int isEmailVerified; + int isEmailVerified = 0; QString extIDAppleTransfer; QString commentShadowColor; - bool isFreePrivateMsgEnabled; - bool isVliverOnlyModeEnabled; + bool isFreePrivateMsgEnabled = false; + bool isVliverOnlyModeEnabled = false; OneSevenLiveOnliveInfo onliveInfo; }; bool JsonToOneSevenLiveUserInfo(const nlohmann::json &json, OneSevenLiveUserInfo &userInfo); struct OneSevenLiveAutoEnter { - bool autoEnter; - qint64 liveStreamID; + bool autoEnter = false; + qint64 liveStreamID = 0; }; struct OneSevenLiveLoginData { @@ -146,15 +146,15 @@ struct OneSevenLiveLoginData { QString refreshToken; QString jwtAccessToken; QString accessToken; - int giftModuleState; + int giftModuleState = 0; QString word; QString abtestNewbieFocus; QString abtestNewbieGuidance; QString abtestNewbieGuide; - bool showRecommend; + bool showRecommend = false; OneSevenLiveAutoEnter autoEnterLive; - int newbieEnhanceGuidanceStyle; - bool newbieGuidanceFocusMissionEnable; + int newbieEnhanceGuidanceStyle = 0; + bool newbieGuidanceFocusMissionEnable = false; }; bool JsonToOneSevenLiveLoginData(const nlohmann::json &json, OneSevenLiveLoginData &loginData); @@ -167,22 +167,22 @@ bool JsonToOneSevenLiveLoginData(const nlohmann::json &json, OneSevenLiveLoginDa } */ struct OneSevenLiveError { - int errorCode; + int errorCode = 0; QString errorMessage; QString errorTitle; }; // RTMP URL information struct struct OneSevenLiveRtmpUrl { - int provider; + int provider = 0; QString streamType; QString url; QString urlLowQuality; QString webUrl; QString webUrlLowQuality; QString urlHighQuality; - int weight; - bool throttle; + int weight = 0; + bool throttle = false; }; bool JsonToOneSevenLiveRtmpUrl(const nlohmann::json &json, OneSevenLiveRtmpUrl &rtmpUrl); @@ -190,7 +190,7 @@ bool JsonToOneSevenLiveRtmpUrl(const nlohmann::json &json, OneSevenLiveRtmpUrl & // Pull stream URL information struct struct OneSevenLivePullUrlsInfo { QList rtmpURLs; - qint64 seqNo; + qint64 seqNo = 0; }; bool JsonToOneSevenLiveRtmpUrls(const nlohmann::json &json, QList &rtmpUrls); @@ -199,11 +199,11 @@ bool JsonToOneSevenLivePullUrlsInfo(const nlohmann::json &pullUrlsInfoJson, // Product information struct struct OneSevenLiveCommodityInfo { - int type; - int price; - int amount; + int type = 0; + int price = 0; + int amount = 0; QString desc; - qint64 endTimeMS; + qint64 endTimeMS = 0; }; // Event icon information struct @@ -214,14 +214,14 @@ struct OneSevenLiveEventIcon { // Event information struct struct OneSevenLiveEventInfo { - qint64 ID; - int type; + qint64 ID = 0; + int type = 0; QString icon; - qint64 endTime; - int showTimer; + qint64 endTime = 0; + int showTimer = 0; QString name; QString URL; - int pageSize; + int pageSize = 0; QString webViewTitle; QList icons; QList webViewTitles; @@ -229,8 +229,8 @@ struct OneSevenLiveEventInfo { // Glory road information struct struct OneSevenLiveGloryroadInfo { - int point; - int level; + int point = 0; + int level = 0; QString iconURL; QString badgeIconURL; }; @@ -242,41 +242,41 @@ bool OneSevenLiveGloryroadInfoToJson(const OneSevenLiveGloryroadInfo &gloryroadI // League information struct struct OneSevenLiveLeagueInfo { - bool shouldShowEntrance; + bool shouldShowEntrance = false; }; struct OneSevenLiveUserArmyInfo { - int joinCount; + int joinCount = 0; }; // User information struct struct OneSevenLiveStreamUserInfo : public OneSevenLiveUserInfo { QString gender; - bool isChoice; - bool isInternational; - int adsOn; - qint64 subscribeExpireTime; - int experience; + bool isChoice = false; + bool isInternational = false; + int adsOn = 0; + qint64 subscribeExpireTime = 0; + int experience = 0; QString version; QString deviceType; QString createClanID; OneSevenLiveUserArmyInfo clanInfo; - int chatMuteDuration; + int chatMuteDuration = 0; QString language; QString registerRegion; - int vipGroupType; - int followReminder; + int vipGroupType = 0; + int followReminder = 0; OneSevenLiveLeagueInfo leagueInfo; - bool hasVipPurchase; - bool disableMakeLiveHotToast; + bool hasVipPurchase = false; + bool disableMakeLiveHotToast = false; OneSevenLiveGloryroadInfo gloryroadInfo; }; struct OneSevenLiveArchiveConfig { - bool autoRecording; - bool autoPublish; - int clipPermission; - int clipPermissionDownload; // New field + bool autoRecording = false; + bool autoPublish = false; + int clipPermission = 0; + int clipPermissionDownload = 0; // New field }; bool JsonToOneSevenLiveArchiveConfig(const nlohmann::json &json, @@ -285,63 +285,63 @@ bool JsonToOneSevenLiveArchiveConfig(const nlohmann::json &json, // Main room information struct struct OneSevenLiveRoomInfo { QString userID; - int streamerType; + int streamerType = 0; QString streamType; - int status; + int status = 0; QString caption; QString thumbnail; QList rtmpUrls; OneSevenLivePullUrlsInfo pullURLsInfo; - int allowCallin; + int allowCallin = 0; QString restreamerOpenID; QString streamID; - qint64 liveStreamID; - qint64 endTime; - qint64 beginTime; - qint64 receivedLikeCount; - int duration; - int viewerCount; - qint64 totalViewTime; - int liveViewerCount; - int audioOnly; + qint64 liveStreamID = 0; + qint64 endTime = 0; + qint64 beginTime = 0; + qint64 receivedLikeCount = 0; + int duration = 0; + int viewerCount = 0; + qint64 totalViewTime = 0; + int liveViewerCount = 0; + int audioOnly = 0; QString locationName; QString coverPhoto; - double latitude; - double longitude; - int shareLocation; - int followerOnlyChat; - int chatAvailable; - int replayCount; - int replayAvailable; - int numberOfChunks; - int canSendGift; + double latitude = 0.0; + double longitude = 0.0; + int shareLocation = 0; + int followerOnlyChat = 0; + int chatAvailable = 0; + int replayCount = 0; + int replayAvailable = 0; + int numberOfChunks = 0; + int canSendGift = 0; OneSevenLiveStreamUserInfo userInfo; - bool landscape; - bool mute; - int birthdayState; - int dayBeforeBirthday; - int achievementValue; - int mediaMessageReadState; + bool landscape = false; + bool mute = false; + int birthdayState = 0; + int dayBeforeBirthday = 0; + int achievementValue = 0; + int mediaMessageReadState = 0; QString region; - int specialTag; + int specialTag = 0; QString guardianUserID; QString guardianPicture; QString campaignIcon; QString campaignURL; - qint64 campaignEndTime; - int campaignShowTimer; - int campaignSize; + qint64 campaignEndTime = 0; + int campaignShowTimer = 0; + int campaignSize = 0; QString campaignTitle; - int commodityState; + int commodityState = 0; OneSevenLiveCommodityInfo commodityInfo; - bool canSellCommodity; - int gridStyle; + bool canSellCommodity = false; + int gridStyle = 0; QString device; QList eventList; OneSevenLiveArchiveConfig archiveConfig; // Add archive configuration QString archiveID; // Add archive ID - bool hideGameMarquee; // Add game marquee hide flag - bool enableOBSGroupCall; // Add OBS group call enable flag + bool hideGameMarquee = false; // Add game marquee hide flag + bool enableOBSGroupCall = false; // Add OBS group call enable flag QStringList subtabs; QList lastUsedHashtags; }; @@ -352,15 +352,15 @@ bool OneSevenLiveRoomInfoToJson(const OneSevenLiveRoomInfo &roomInfo, nlohmann:: // Virtual streamer information struct struct OneSevenLiveVliverInfo { - int vliverModel; + int vliverModel = 0; }; // Army settings struct OneSevenLiveArmy { - bool armyOnlyPN; - bool enable; - int requiredArmyRank; - bool showOnHotPage; + bool armyOnlyPN = false; + bool enable = false; + int requiredArmyRank = 0; + bool showOnHotPage = false; }; // RTMP request struct @@ -368,15 +368,15 @@ struct OneSevenLiveRtmpRequest { QString userID; QString caption; QString device; - qint64 eventID; + qint64 eventID = 0; QStringList hashtags; - bool landscape; - int streamerType; + bool landscape = false; + int streamerType = 0; QString subtabID; OneSevenLiveArchiveConfig archiveConfig; OneSevenLiveVliverInfo vliverInfo; OneSevenLiveArmy armyOnly; - bool enableOBSGroupCall; + bool enableOBSGroupCall = false; }; bool OneSevenLiveRtmpRequestToJson(const OneSevenLiveRtmpRequest &request, nlohmann::json &json); @@ -394,8 +394,8 @@ bool JsonToOneSevenLiveStreamInfo(const nlohmann::json &json, OneSevenLiveStream // Achievement value status struct struct OneSevenLiveAchievementValueState { - bool isValueCarryOver; - int initSeconds; + bool isValueCarryOver = false; + int initSeconds = 0; }; // WHIP information struct @@ -410,11 +410,11 @@ struct OneSevenLiveRtmpResponse { QString streamID; QString rtmpURL; QString rtmpProvider; - int messageProvider; + int messageProvider = 0; Json firstStreamInfo; // Use Json type because it's an empty object QList rtmpURLs; // Reuse existing OneSevenLiveRtmpUrl struct OneSevenLiveAchievementValueState achievementValueState; - bool subtitleEnabled; + bool subtitleEnabled = false; OneSevenLiveWhipInfo whipInfo; // WHIP information }; @@ -437,7 +437,7 @@ struct OneSevenLiveEventTag { // Ably Token response struct struct OneSevenLiveAblyTokenResponse { - int provider; + int provider = 0; QString token; QStringList channels; }; @@ -449,19 +449,19 @@ bool OneSevenLiveAblyTokenResponseToJson(const OneSevenLiveAblyTokenResponse &re // Event item struct struct OneSevenLiveEventItem { - qint64 ID; + qint64 ID = 0; QString name; QString bannerURL; QString descriptionURL; QStringList tagIDs; - qint64 endTime; + qint64 endTime = 0; }; // Event list struct struct OneSevenLiveEventList { QList events; - bool notEligibleForAllEvents; - int promotionIndex; + bool notEligibleForAllEvents = false; + int promotionIndex = 0; QList tags; QString instructionURL; }; @@ -469,10 +469,10 @@ struct OneSevenLiveEventList { // Gift struct struct OneSevenLiveGift { QString giftID; - int isHidden; - int regionMode; + int isHidden = 0; + int regionMode = 0; QString name; - int point; + int point = 0; QString leaderboardIcon; QString vffURL; QString vffMD5; @@ -484,17 +484,17 @@ struct OneSevenLiveGift { struct OneSevenLiveCustomEvent { QString eventID; QString userID; - int status; + int status = 0; QString eventName; QString description; - qint64 startTime; - qint64 endTime; - qint64 realEndTime; - bool isAchieved; + qint64 startTime = 0; + qint64 endTime = 0; + qint64 realEndTime = 0; + bool isAchieved = false; QList giftIDs; QList gifts; - qint64 goalPoints; - qint64 dailyGoalPoints; + qint64 goalPoints = 0; + qint64 dailyGoalPoints = 0; QString displayStatus; QList rewards; // Using Json type because rewards structure is not defined qint64 currentGoalPoints; @@ -503,7 +503,7 @@ struct OneSevenLiveCustomEvent { // Stop custom event request struct struct OneSevenLiveCustomEventStatusRequest { - int status; // Status 2 means stop + int status = 0; // Status 2 means stop QString userID; }; @@ -514,7 +514,7 @@ bool OneSevenLiveChangeCustomEventStatusRequestToJson( // Box gacha struct struct OneSevenLiveBoxGacha { - bool previousSettingStatus; + bool previousSettingStatus = false; QString availableEventID; }; @@ -527,14 +527,14 @@ struct OneSevenLiveSubtab { // Gift tab struct struct OneSevenLiveGiftTab { QString id; - int type; + int type = 0; QString name; QList gifts; }; // Gift tabs response struct struct OneSevenLiveGiftTabsResponse { - qint64 giftLastUpdate; + qint64 giftLastUpdate = 0; QList tabs; }; @@ -555,8 +555,8 @@ struct OneSevenLiveConfigStreamer { OneSevenLiveBoxGacha boxGacha; QList subtabs; OneSevenLiveStreamState lastStreamState; - int hashtagSelectLimit; - int armyOnly; + int hashtagSelectLimit = 0; + int armyOnly = 0; OneSevenLiveArchiveConfig archiveConfig; }; @@ -613,8 +613,8 @@ struct OneSevenLiveI18nToken { // Army subscription level struct struct OneSevenLiveArmySubscriptionLevel { - int rank; // Level ranking - int subscribersAmount; // Number of subscribers + int rank = 0; // Level ranking + int subscribersAmount = 0; // Number of subscribers OneSevenLiveI18nToken i18nToken; // Internationalization token }; @@ -631,7 +631,7 @@ bool OneSevenLiveArmySubscriptionLevelsToJson(const OneSevenLiveArmySubscription // Gifts response struct struct OneSevenLiveGiftsResponse { - qint64 lastUpdate; + qint64 lastUpdate = 0; QList gifts; }; @@ -654,70 +654,70 @@ struct OneSevenLiveArmyInfoUser { QString displayName; QString picture; QString name; - int level; + int level = 0; QString openID; QString region; OneSevenLiveGloryroadInfo gloryroadInfo; - int gloryroadMode; + int gloryroadMode = 0; }; // Army info struct for rock zone viewer struct OneSevenLiveArmyInfo { OneSevenLiveArmyInfoUser user; - int rank; - qint64 pointContribution; - int seniority; - qint64 startTime; - qint64 endTime; - bool isOnLive; - int newStatus; - qint64 periodStartTime; + int rank = 0; + qint64 pointContribution = 0; + int seniority = 0; + qint64 startTime = 0; + qint64 endTime = 0; + bool isOnLive = false; + int newStatus = 0; + qint64 periodStartTime = 0; }; // User attributes struct for rock zone viewer struct OneSevenLiveUserAttr { - int level; - int sentPoint; - int checkinLevel; - int checkinCount; + int level = 0; + int sentPoint = 0; + int checkinLevel = 0; + int checkinCount = 0; QString checkinBdgURL; - int noteStatus; - int followStatus; - int gloryroadMode; + int noteStatus = 0; + int followStatus = 0; + int gloryroadMode = 0; OneSevenLiveGloryroadInfo gloryroadInfo; }; // Anonymous info struct for rock zone viewer struct OneSevenLiveAnonymousInfo { - bool isInvisible; + bool isInvisible = false; QString pureText; }; // Display user struct for rock zone viewer struct OneSevenLiveDisplayUser { - int armyRank; + int armyRank = 0; QString badgeURL; QString bgColor; QString checkinBdgURL; - int checkinLevel; + int checkinLevel = 0; QString circleBadgeURL; QString displayName; QString fgColor; OneSevenLiveGloryroadInfo gloryroadInfo; - int gloryroadMode; - bool hasProgram; - bool isDirty; - bool isDirtyUser; - bool isGuardian; - bool isProducer; - bool isStreamer; - bool isVIP; - int level; - int mLevel; + int gloryroadMode = 0; + bool hasProgram = false; + bool isDirty = false; + bool isDirtyUser = false; + bool isGuardian = false; + bool isProducer = false; + bool isStreamer = false; + bool isVIP = false; + int level = 0; + int mLevel = 0; QString pfxBadgeURL; QString picture; - int producer; - int program; + int producer = 0; + int program = 0; QString topRightIconURL; QString userID; QString vipCharmURL; @@ -727,23 +727,49 @@ struct OneSevenLiveDisplayUser { struct OneSevenLiveGiftRankOne { QString displayName; QString picture; - qint64 timestampMs; + qint64 timestampMs = 0; QString userID; }; -// Rock zone viewer struct +// Guardian Owner struct +struct OneSevenLiveGuardianOwner { + QString userID; + QString displayName; + QString picture; + QString name; + int level = 0; + QString openID; + QString region; + int gloryroadMode = 0; +}; + +// Guardian struct +struct OneSevenLiveGuardian { + OneSevenLiveGuardianOwner owner; + int bidPrice = 0; + qint64 expireTime = 0; +}; + +// Rock Zone Viewer struct struct OneSevenLiveRockZoneViewer { - int type; + int type = 0; QList badgeTypes; // just for merge badge OneSevenLiveArmyInfo armyInfo; OneSevenLiveLabelToken labelToken; OneSevenLiveUserAttr userAttr; OneSevenLiveAnonymousInfo anonymousInfo; - int armyLevel; + int armyLevel = 0; OneSevenLiveDisplayUser displayUser; OneSevenLiveGiftRankOne giftRankOne; + OneSevenLiveGuardian guardian; }; +// Function declarations for guardian JSON conversion +bool JsonToOneSevenLiveGuardianOwner(const nlohmann::json &json, OneSevenLiveGuardianOwner &owner); +bool OneSevenLiveGuardianOwnerToJson(const OneSevenLiveGuardianOwner &owner, nlohmann::json &json); +bool JsonToOneSevenLiveGuardian(const nlohmann::json &json, OneSevenLiveGuardian &guardian); +bool OneSevenLiveGuardianToJson(const OneSevenLiveGuardian &guardian, nlohmann::json &json); + // Function declarations for gift rank one JSON conversion bool JsonToOneSevenLiveGiftRankOne(const nlohmann::json &json, OneSevenLiveGiftRankOne &giftRankOne); @@ -765,6 +791,9 @@ bool OneSevenLiveRockZoneViewerToJson(const OneSevenLiveRockZoneViewer &viewer, bool JsonToOneSevenLiveRockViewers(const nlohmann::json &json, QList &viewers); +QList SortOneSevenLiveRockZoneViewers( + QList &viewers); + // Army name struct struct OneSevenLiveArmyName { QString customName; diff --git a/src/17live/api/OneSevenLiveUtility.cpp b/src/17live/api/OneSevenLiveUtility.cpp index facaa40..5b7541a 100644 --- a/src/17live/api/OneSevenLiveUtility.cpp +++ b/src/17live/api/OneSevenLiveUtility.cpp @@ -74,7 +74,7 @@ QString OneSevenLiveUtility::displayOpenID(const OneSevenLiveRockZoneViewer &vie } QString OneSevenLiveUtility::checkingLevelBadgeResource(const OneSevenLiveRockZoneViewer &viewer) { - const int lv = viewer.displayUser.checkinLevel; + const int lv = viewer.userAttr.checkinLevel; switch (lv) { case 0: case 1: @@ -98,15 +98,6 @@ QString OneSevenLiveUtility::checkingLevelBadgeResource(const OneSevenLiveRockZo } } -static inline bool isZh(const QString &locale) { - return locale.startsWith("zh", Qt::CaseInsensitive); -} - -static inline bool isJa(const QString &locale) { - return locale.startsWith("ja", Qt::CaseInsensitive) || - locale.startsWith("jp", Qt::CaseInsensitive); -} - QString OneSevenLiveUtility::badgeLabel(int badgeType, int rank, const OneSevenLiveArmyNameResponse *armyResp) { switch (badgeType) { diff --git a/src/17live/chat/OneSevenLiveChatMessageHandler.cpp b/src/17live/chat/OneSevenLiveChatMessageHandler.cpp new file mode 100644 index 0000000..3206f51 --- /dev/null +++ b/src/17live/chat/OneSevenLiveChatMessageHandler.cpp @@ -0,0 +1,176 @@ +#include "OneSevenLiveChatMessageHandler.hpp" + +#include +#include + +#include +#include +#include + +#include "../OneSevenLiveCoreManager.hpp" +#include "api/OneSevenLiveAblyChatClient.hpp" +#include "plugin-support.h" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WsMessage.hpp" + +bool OneSevenLiveChatMessageHandler::handleRaw(const std::string& msg) { + try { + nlohmann::json j = nlohmann::json::parse(msg); + if (j.contains("messages") && j["messages"].is_array()) { + for (auto& m : j["messages"]) { + if (!m.contains("data") || !m["data"].is_string()) + continue; + nlohmann::json decoded; + if (!gunzipBase64ToJson(m["data"].get(), decoded)) + continue; + int type = decoded.contains("type") && decoded["type"].is_number_integer() + ? decoded["type"].get() + : -1; + routeByType(type, decoded); + } + } + return true; + } catch (...) { + return false; + } +} + +bool OneSevenLiveChatMessageHandler::gunzipBase64ToJson(const std::string& base64Data, + nlohmann::json& out) { + QByteArray raw = QByteArray::fromBase64(QByteArray::fromStdString(base64Data)); + if (raw.isEmpty()) + return false; + QByteArray outBuf; + z_stream zs{}; + zs.next_in = reinterpret_cast(raw.data()); + zs.avail_in = raw.size(); + if (inflateInit2(&zs, 15 + 16) != Z_OK) + return false; + char buf[4096]; + int ret; + do { + zs.next_out = reinterpret_cast(buf); + zs.avail_out = sizeof(buf); + ret = inflate(&zs, Z_NO_FLUSH); + if (ret != Z_OK && ret != Z_STREAM_END) + break; + int have = sizeof(buf) - zs.avail_out; + if (have > 0) + outBuf.append(buf, have); + } while (ret != Z_STREAM_END); + inflateEnd(&zs); + if (ret != Z_STREAM_END) + return false; + try { + out = nlohmann::json::parse(outBuf.constData()); + return true; + } catch (...) { + return false; + } +} + +void OneSevenLiveChatMessageHandler::routeByType(int type, const nlohmann::json& decoded) { + using namespace ws; + switch (type) { + case ably::MsgType_JOIN_ROOM: + case ably::MsgType_NEW_GIFT: + case ably::MsgType_NEW_LUCKYBAG: + case ably::MsgType_POKE: + case ably::MsgType_AI_COHOST_MESSAGE: + case ably::MsgType_COMMENT: + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(EventAblyChatMessage), decoded); + if (type == ably::MsgType_NEW_LUCKYBAG || type == ably::MsgType_NEW_GIFT) + handleGiftPlayback(decoded); + break; + case ably::MsgType_ROCKZONE: + obs_log(LOG_DEBUG, "ROCKZONE"); + QMetaObject::invokeMethod( + &OneSevenLiveCoreManager::getInstance(), + []() { + auto& core = OneSevenLiveCoreManager::getInstance(); + core.refreshRockZoneUserList(); + }, + Qt::QueuedConnection); + break; + default: + obs_log(LOG_DEBUG, "Unknown chat message type: %d", type); + break; + } +} + +void OneSevenLiveChatMessageHandler::handleGiftPlayback(const nlohmann::json& decoded) { + obs_log(LOG_DEBUG, "Gift playback message received %s", decoded.dump().c_str()); + + try { + std::string giftID; + std::string extID; + nlohmann::json gm; + if (decoded.contains("giftMsg") && decoded["giftMsg"].is_object()) { + gm = decoded["giftMsg"]; + if (gm.contains("giftID") && gm["giftID"].is_string()) + giftID = gm["giftID"].get(); + if (gm.contains("extID") && gm["extID"].is_string()) + extID = gm["extID"].get(); + } + + auto& core = OneSevenLiveCoreManager::getInstance(); + + auto hasVFF = [](const std::optional& g) -> bool { + return g && g->contains("vffURL") && g->contains("vffJson") && + (*g)["vffURL"].is_string() && !(*g)["vffURL"].get().empty() && + (*g)["vffJson"].is_string() && !(*g)["vffJson"].get().empty(); + }; + + auto sendById = [&](const std::string& id, bool attachComposite) { + if (id.empty()) + return false; + auto gift = core.getGiftByID(id); + if (!hasVFF(gift)) + return false; + nlohmann::json playData; + playData["type"] = "play_vff"; + playData["vffURL"] = (*gift)["vffURL"].get(); + playData["vffJson"] = (*gift)["vffJson"].get(); + if (attachComposite) { + try { + if (gm.contains("giftMetas") && gm["giftMetas"].is_array() && + !gm["giftMetas"].empty()) { + const auto& meta0 = gm["giftMetas"][0]; + if (meta0.contains("composite") && meta0["composite"].is_array()) { + nlohmann::json compositeObj = nlohmann::json::object(); + for (const auto& item : meta0["composite"]) { + if (item.contains("tag") && item.contains("imageURL") && + item["tag"].is_string() && item["imageURL"].is_string()) { + compositeObj[item["tag"].get()] = + item["imageURL"].get(); + } + } + if (!compositeObj.empty()) + playData["compositeData"] = compositeObj; + } + } + } catch (...) { + } + } + auto* ws = core.getWebsocketServer(); + if (ws && ws->is_running()) { + ws->broadcastMessage(playData.dump()); + return true; + } + return false; + }; + + bool sentExt = sendById(extID, false); + bool sentGift = sendById(giftID, true); + if (!sentExt && !sentGift) { + obs_log(LOG_WARNING, "Missing VFF fields for giftID=%s and extID=%s. message=%s", + giftID.c_str(), extID.c_str(), decoded.dump().c_str()); + } + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "Exception in handleGiftPlayback: %s", e.what()); + } catch (...) { + obs_log(LOG_ERROR, "Unknown exception in handleGiftPlayback"); + } +} diff --git a/src/17live/chat/OneSevenLiveChatMessageHandler.hpp b/src/17live/chat/OneSevenLiveChatMessageHandler.hpp new file mode 100644 index 0000000..af6066a --- /dev/null +++ b/src/17live/chat/OneSevenLiveChatMessageHandler.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include + +class OneSevenLiveChatMessageHandler { + public: + bool handleRaw(const std::string& msg); + + private: + static bool gunzipBase64ToJson(const std::string& base64Data, nlohmann::json& out); + static void routeByType(int type, const nlohmann::json& decoded); + static void handleGiftPlayback(const nlohmann::json& decoded); +}; diff --git a/src/17live/chat/OneSevenLiveChatWidget.cpp b/src/17live/chat/OneSevenLiveChatWidget.cpp new file mode 100644 index 0000000..c025bc2 --- /dev/null +++ b/src/17live/chat/OneSevenLiveChatWidget.cpp @@ -0,0 +1,156 @@ +#include "OneSevenLiveChatWidget.hpp" + +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveCoreManager.hpp" +#include "cef_panel.hpp" +#include "plugin-support.h" + +OneSevenLiveChatWidget::OneSevenLiveChatWidget(QWidget* parent, const QString& chatUrl) + : QWidget(parent), chatUrl_(chatUrl) { + obs_log(LOG_INFO, "OneSevenLiveChatWidget constructed"); + + // Making this a native window often helps with embedding native child windows (CEF) + this->setAttribute(Qt::WA_NativeWindow); + + static QCef* globalCef = nullptr; + if (!globalCef) { + globalCef = obs_browser_init_panel(); + if (globalCef) { + if (!globalCef->initialized()) { + globalCef->init_browser(); + } + } + } + cef_ = globalCef; + + if (cef_) { + // cef_->init_browser(); // Already initialized globally + cefWidget_ = cef_->create_widget(this, chatUrl_.toStdString()); + if (cefWidget_) { + int panel_version = obs_browser_qcef_version(); + if (panel_version >= 1) { + cefWidget_->allowAllPopups(true); + } + } else { + obs_log(LOG_ERROR, "Failed to create QCefWidget"); + errorLabel_ = new QLabel("Failed to create CEF widget", this); + errorLabel_->setAlignment(Qt::AlignCenter); + } + } else { + obs_log(LOG_WARNING, "Browser panels unavailable (obs-browser missing or Wayland)"); + errorLabel_ = new QLabel("Browser source not available", this); + errorLabel_->setAlignment(Qt::AlignCenter); + } + + QVBoxLayout* rootLayout = new QVBoxLayout(this); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + if (cefWidget_) { + rootLayout->addWidget(cefWidget_); + } else if (errorLabel_) { + rootLayout->addWidget(errorLabel_); + } + + // Loading overlay + loadingOverlay = new QWidget(this); + loadingOverlay->setStyleSheet("background-color: rgba(0, 0, 0, 180);"); + + QVBoxLayout* overlayLayout = new QVBoxLayout(loadingOverlay); + overlayLayout->setAlignment(Qt::AlignCenter); + + loadingLabel = new QLabel(obs_module_text("ChatRoom.LoadingGifts"), loadingOverlay); + loadingLabel->setStyleSheet("color: white; font-size: 16px; font-weight: bold;"); + overlayLayout->addWidget(loadingLabel); + + // Raise overlay to top + loadingOverlay->raise(); + + auto& core = OneSevenLiveCoreManager::getInstance(); + connect(&core, &OneSevenLiveCoreManager::giftsLoaded, this, + &OneSevenLiveChatWidget::onGiftsLoaded); + + if (core.isGiftsLoaded()) { + loadingOverlay->hide(); + } else { + loadingOverlay->show(); + } +} + +OneSevenLiveChatWidget::~OneSevenLiveChatWidget() { + obs_log(LOG_INFO, "OneSevenLiveChatWidget destructor called"); + if (cefWidget_ && !browserClosed_) { + int panel_version = obs_browser_qcef_version(); + if (panel_version >= 2) { + obs_log(LOG_INFO, "Closing CEF browser in destructor"); + cefWidget_->closeBrowser(); + browserClosed_ = true; + } + } +} + +void OneSevenLiveChatWidget::shutdown() { + obs_log(LOG_INFO, "OneSevenLiveChatWidget shutdown called"); + if (cefWidget_ && !browserClosed_) { + int panel_version = obs_browser_qcef_version(); + if (panel_version >= 2) { + obs_log(LOG_INFO, "Closing CEF browser in shutdown"); + cefWidget_->closeBrowser(); + browserClosed_ = true; + } + } +} + +void OneSevenLiveChatWidget::setUrl(const QString& url) { + chatUrl_ = url; + if (cefWidget_) { + cefWidget_->setURL(chatUrl_.toStdString()); + } +} + +void OneSevenLiveChatWidget::reload() { + if (cefWidget_) { + cefWidget_->reloadPage(); + } +} + +void OneSevenLiveChatWidget::showEvent(QShowEvent* event) { + obs_log(LOG_INFO, "OneSevenLiveChatWidget showEvent"); + QWidget::showEvent(event); + + if (cefWidget_) { + cefWidget_->setVisible(true); + } + if (loadingOverlay && loadingOverlay->isVisible()) { + loadingOverlay->raise(); + } +} + +void OneSevenLiveChatWidget::hideEvent(QHideEvent* event) { + obs_log(LOG_INFO, "OneSevenLiveChatWidget hideEvent"); + QWidget::hideEvent(event); + if (cefWidget_) { + cefWidget_->setVisible(false); + } +} + +void OneSevenLiveChatWidget::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + if (loadingOverlay) { + loadingOverlay->setGeometry(contentsRect()); + } + if (errorLabel_) { + errorLabel_->setGeometry(contentsRect()); + } +} + +void OneSevenLiveChatWidget::onGiftsLoaded() { + if (loadingOverlay) { + loadingOverlay->hide(); + } +} diff --git a/src/17live/chat/OneSevenLiveChatWidget.hpp b/src/17live/chat/OneSevenLiveChatWidget.hpp new file mode 100644 index 0000000..0dc8f58 --- /dev/null +++ b/src/17live/chat/OneSevenLiveChatWidget.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include + +class QLabel; +class QCefWidget; +struct QCef; + +class OneSevenLiveChatWidget : public QWidget { + Q_OBJECT + + public: + explicit OneSevenLiveChatWidget(QWidget* parent, const QString& chatUrl); + ~OneSevenLiveChatWidget(); + + void setUrl(const QString& url); + void reload(); + void shutdown(); + + protected: + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + private slots: + void onGiftsLoaded(); + + private: + QString chatUrl_; + QCefWidget* cefWidget_ = nullptr; + QCef* cef_ = nullptr; + + QWidget* loadingOverlay = nullptr; + QLabel* loadingLabel = nullptr; + QLabel* errorLabel_ = nullptr; + + bool browserClosed_ = false; +}; diff --git a/src/17live/chat/cef_panel.hpp b/src/17live/chat/cef_panel.hpp new file mode 100644 index 0000000..ca26144 --- /dev/null +++ b/src/17live/chat/cef_panel.hpp @@ -0,0 +1,118 @@ +// Minimal copy of the obs-browser panel interface used to access CEF-based widgets +// This header provides QCef/QCefWidget interfaces and helper functions to obtain them + +#pragma once + +#include +#include + +template +class BPtr; + +// Prefer explicit Qt module includes +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#endif + +#ifdef ENABLE_WAYLAND +#include +#endif + +struct QCefCookieManager { + virtual ~QCefCookieManager() {} + + virtual bool DeleteCookies(const std::string &url, const std::string &name) = 0; + virtual bool SetStoragePath(const std::string &storage_path, + bool persist_session_cookies = false) = 0; + virtual bool FlushStore() = 0; + + typedef std::function cookie_exists_cb; + + virtual void CheckForCookie(const std::string &site, const std::string &cookie, + cookie_exists_cb callback) = 0; +}; + +class QCefWidget : public QWidget { + protected: + inline QCefWidget(QWidget *parent) : QWidget(parent) {} + + public: + virtual void setURL(const std::string &url) = 0; + virtual void setStartupScript(const std::string &script) = 0; + virtual void allowAllPopups(bool allow) = 0; + virtual void closeBrowser() = 0; + virtual void reloadPage() = 0; + virtual bool zoomPage(int direction) = 0; + virtual void executeJavaScript(const std::string &script) = 0; +}; + +struct QCef { + virtual ~QCef() {} + + virtual bool init_browser(void) = 0; + virtual bool initialized(void) = 0; + virtual bool wait_for_browser_init(void) = 0; + + virtual QCefWidget *create_widget(QWidget *parent, const std::string &url, + QCefCookieManager *cookie_manager = nullptr) = 0; + + virtual QCefCookieManager *create_cookie_manager(const std::string &storage_path, + bool persist_session_cookies = false) = 0; + + virtual BPtr get_cookie_path(const std::string &storage_path) = 0; + + virtual void add_popup_whitelist_url(const std::string &url, QObject *obj) = 0; + virtual void add_force_popup_url(const std::string &url, QObject *obj) = 0; +}; + +static inline void *get_browser_lib() { + // Disable panels on Wayland for now + bool isWayland = false; +#ifdef ENABLE_WAYLAND + isWayland = obs_get_nix_platform() == OBS_NIX_PLATFORM_WAYLAND; +#endif + if (isWayland) + return nullptr; + + obs_module_t *browserModule = obs_get_module("obs-browser"); + + if (!browserModule) + return nullptr; + + return obs_get_module_lib(browserModule); +} + +static inline QCef *obs_browser_init_panel(void) { + void *lib = get_browser_lib(); + QCef *(*create_qcef)(void) = nullptr; + + if (!lib) + return nullptr; + + create_qcef = (decltype(create_qcef)) os_dlsym(lib, "obs_browser_create_qcef"); + + if (!create_qcef) + return nullptr; + + return create_qcef(); +} + +static inline int obs_browser_qcef_version(void) { + void *lib = get_browser_lib(); + int (*qcef_version)(void) = nullptr; + + if (!lib) + return 0; + + qcef_version = (decltype(qcef_version)) os_dlsym(lib, "obs_browser_qcef_version_export"); + + if (!qcef_version) + return 0; + + return qcef_version(); +} diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.cpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.cpp new file mode 100644 index 0000000..2711940 --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.cpp @@ -0,0 +1,450 @@ +#include "OneSevenLiveMultiRtmpConfigManager.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" + +OneSevenLiveMultiRtmpConfigManager::OneSevenLiveMultiRtmpConfigManager() { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] ConfigManager initialized"); + + // Initialize configuration directory path - use same directory as OneSevenLiveConfigManager + QString homeDir = QDir::homePath(); + QString configDir = homeDir + "/.17Live"; + QDir dir(configDir); + + // If directory doesn't exist, create it + if (!dir.exists()) { + if (!dir.mkpath(configDir)) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to create config directory: %s", + configDir.toStdString().c_str()); + m_configDirectory = "./config/multi-rtmp"; // fallback + } else { + m_configDirectory = configDir.toStdString(); + } + } else { + m_configDirectory = configDir.toStdString(); + } + + m_configFilePath = m_configDirectory + "/" + CONFIG_FILE_NAME; + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Config directory: %s", m_configDirectory.c_str()); + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Config file path: %s", m_configFilePath.c_str()); + + // Ensure config directory exists + ensureConfigDirectoryExists(); +} + +OneSevenLiveMultiRtmpConfigManager::~OneSevenLiveMultiRtmpConfigManager() { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Skipping save on destruction during shutdown"); + } else { + saveConfiguration(); + } + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Configuration manager destroyed"); +} + +bool OneSevenLiveMultiRtmpConfigManager::loadConfiguration() { + return loadConfigurationInternal(); +} + +bool OneSevenLiveMultiRtmpConfigManager::saveConfiguration() { + if (!writeConfigToFile(m_globalConfig)) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to save configuration to file: %s", + m_configFilePath.c_str()); + return false; + } + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Configuration saved successfully"); + return true; +} + +bool OneSevenLiveMultiRtmpConfigManager::forceSave() { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Force saving configuration"); + return saveConfigurationInternal(); +} + +std::string OneSevenLiveMultiRtmpConfigManager::getConfigFilePath() const { + return m_configFilePath; +} + +bool OneSevenLiveMultiRtmpConfigManager::addStreamConfig( + const OneSevenLiveMultiRtmpConfig& config) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Adding stream config: %s", + config.streamName.c_str()); + + // Check if stream with same ID already exists + if (m_globalConfig.findStream(config.id)) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Stream with ID %s already exists", + config.id.c_str()); + return false; + } + + // Add to global config + m_globalConfig.streams.push_back(config); + + // Notify callback + notifyConfigChange(config.id, config); + + return true; +} + +bool OneSevenLiveMultiRtmpConfigManager::removeStreamConfig(const std::string& streamId) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Removing stream config: %s", streamId.c_str()); + + // Find the config to remove + auto* configToRemove = m_globalConfig.findStream(streamId); + if (!configToRemove) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Stream with ID %s not found", + streamId.c_str()); + return false; + } + + // Remove from global config + auto it = std::find_if( + m_globalConfig.streams.begin(), m_globalConfig.streams.end(), + [&streamId](const OneSevenLiveMultiRtmpConfig& stream) { return stream.id == streamId; }); + if (it != m_globalConfig.streams.end()) { + m_globalConfig.streams.erase(it); + } + + // Notify callback + notifyConfigDelete(streamId); + + return true; +} + +bool OneSevenLiveMultiRtmpConfigManager::updateStreamConfig( + const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Updating stream config: %s", streamId.c_str()); + + OneSevenLiveMultiRtmpConfig updatedConfig = config; + + auto* existingConfig = m_globalConfig.findStream(streamId); + if (!existingConfig) { + obs_log(LOG_ERROR, + "[MultiRTMP-ConfigManager] Stream configuration not found for update: %s", + streamId.c_str()); + return false; + } + + // Update the configuration + *existingConfig = updatedConfig; + + // Notify callback + notifyConfigChange(streamId, updatedConfig); + + return true; +} + +std::vector OneSevenLiveMultiRtmpConfigManager::getStreamConfigs() + const { + return m_globalConfig.streams; +} + +OneSevenLiveMultiRtmpConfig OneSevenLiveMultiRtmpConfigManager::getStreamConfig( + const std::string& streamId) const { + const auto* config = m_globalConfig.findStream(streamId); + if (config) { + return *config; + } + + obs_log(LOG_WARNING, "[MultiRTMP-ConfigManager] Stream configuration not found: %s", + streamId.c_str()); + return OneSevenLiveMultiRtmpConfig(); +} + +bool OneSevenLiveMultiRtmpConfigManager::hasStreamConfig(const std::string& streamId) const { + return m_globalConfig.findStream(streamId) != nullptr; +} + +std::string OneSevenLiveMultiRtmpConfigManager::generateStreamId() const { + // Generate a UUID-like string + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(0, 15); + + std::stringstream ss; + ss << std::hex; + for (int i = 0; i < 8; i++) { + ss << dis(gen); + } + ss << "-"; + for (int i = 0; i < 4; i++) { + ss << dis(gen); + } + ss << "-4"; // Version 4 UUID + for (int i = 0; i < 3; i++) { + ss << dis(gen); + } + ss << "-"; + ss << (8 + (dis(gen) & 3)); // Variant bits + for (int i = 0; i < 3; i++) { + ss << dis(gen); + } + ss << "-"; + for (int i = 0; i < 12; i++) { + ss << dis(gen); + } + + return ss.str(); +} + +std::string OneSevenLiveMultiRtmpConfigManager::getCurrentTimestamp() const { + auto now = std::chrono::system_clock::now(); + auto time_t = std::chrono::system_clock::to_time_t(now); + std::ostringstream oss; + oss << std::put_time(std::gmtime(&time_t), "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} + +size_t OneSevenLiveMultiRtmpConfigManager::getStreamCount() const { + return m_globalConfig.streams.size(); +} + +std::vector OneSevenLiveMultiRtmpConfigManager::getStreamIds() const { + std::vector ids; + ids.reserve(m_globalConfig.streams.size()); + + for (const auto& stream : m_globalConfig.streams) { + ids.push_back(stream.id); + } + + return ids; +} + +void OneSevenLiveMultiRtmpConfigManager::setConfigChangeCallback(ConfigChangeCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_configChangeCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpConfigManager::setConfigDeleteCallback(ConfigDeleteCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_configDeleteCallback = std::move(callback); +} + +bool OneSevenLiveMultiRtmpConfigManager::createBackup() const { + std::string timestamp = getCurrentTimestamp(); + std::replace(timestamp.begin(), timestamp.end(), ':', '-'); + std::string backupPath = getBackupFilePath(timestamp); + + try { + std::filesystem::copy_file(m_configFilePath, backupPath); + return true; + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to create backup: %s", e.what()); + return false; + } +} + +bool OneSevenLiveMultiRtmpConfigManager::restoreFromBackup() { + // Find the most recent backup + auto backups = getAvailableBackups(); + if (backups.empty()) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] No backups available"); + return false; + } + + std::string latestBackup = backups.back(); // Assuming sorted by timestamp + std::string backupPath = m_configDirectory + "/" + latestBackup; + + try { + std::filesystem::copy_file(backupPath, m_configFilePath, + std::filesystem::copy_options::overwrite_existing); + return loadConfiguration(); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to restore from backup: %s", e.what()); + return false; + } +} + +std::vector OneSevenLiveMultiRtmpConfigManager::getAvailableBackups() const { + std::vector backups; + + try { + for (const auto& entry : std::filesystem::directory_iterator(m_configDirectory)) { + if (entry.is_regular_file()) { + std::string filename = entry.path().filename().string(); + if (filename.substr(0, strlen(CONFIG_BACKUP_PREFIX)) == CONFIG_BACKUP_PREFIX && + filename.size() >= strlen(CONFIG_BACKUP_EXTENSION) && + filename.substr(filename.size() - strlen(CONFIG_BACKUP_EXTENSION)) == + CONFIG_BACKUP_EXTENSION) { + backups.push_back(filename); + } + } + } + + // Sort backups by timestamp (filename contains timestamp) + std::sort(backups.begin(), backups.end()); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to list backups: %s", e.what()); + } + + return backups; +} + +bool OneSevenLiveMultiRtmpConfigManager::ensureConfigDirectoryExists() const { + try { + if (!std::filesystem::exists(m_configDirectory)) { + std::filesystem::create_directories(m_configDirectory); + } + return true; + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to create configuration directory: %s", + e.what()); + return false; + } +} + +bool OneSevenLiveMultiRtmpConfigManager::writeConfigToFile( + const OneSevenLiveMultiRtmpGlobalConfig& config) const { + try { + obs_log(LOG_INFO, + "[MultiRTMP-ConfigManager] Preparing to write config with %zu streams to: %s", + config.streams.size(), m_configFilePath.c_str()); + nlohmann::json j = config; + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] JSON content to write: %s", j.dump().c_str()); + + std::ofstream file(m_configFilePath); + if (!file.is_open()) { + obs_log(LOG_ERROR, + "[MultiRTMP-ConfigManager] Failed to open config file for writing: %s", + m_configFilePath.c_str()); + return false; + } + + file << j.dump(4); // Pretty print with 4 spaces + file.close(); + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Successfully wrote config file: %s", + m_configFilePath.c_str()); + return true; + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to write configuration: %s", e.what()); + return false; + } +} + +bool OneSevenLiveMultiRtmpConfigManager::readConfigFromFile( + OneSevenLiveMultiRtmpGlobalConfig& config) const { + try { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Opening config file for reading: %s", + m_configFilePath.c_str()); + std::ifstream file(m_configFilePath); + if (!file.is_open()) { + obs_log(LOG_ERROR, + "[MultiRTMP-ConfigManager] Failed to open config file for reading: %s", + m_configFilePath.c_str()); + return false; + } + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Reading JSON content from config file"); + nlohmann::json j; + file >> j; + file.close(); + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] JSON content: %s", j.dump().c_str()); + + config = j.get(); + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Successfully parsed config with %zu streams", + config.streams.size()); + return true; + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to read configuration: %s", e.what()); + return false; + } +} + +std::string OneSevenLiveMultiRtmpConfigManager::getBackupFilePath( + const std::string& timestamp) const { + return m_configDirectory + "/" + CONFIG_BACKUP_PREFIX + timestamp + CONFIG_BACKUP_EXTENSION; +} + +void OneSevenLiveMultiRtmpConfigManager::notifyConfigChange( + const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + ConfigChangeCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_configChangeCallback; + } + if (cb) { + cb(streamId, config); + } +} + +void OneSevenLiveMultiRtmpConfigManager::notifyConfigDelete(const std::string& streamId) { + ConfigDeleteCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_configDeleteCallback; + } + if (cb) { + cb(streamId); + } +} + +bool OneSevenLiveMultiRtmpConfigManager::saveConfigurationInternal() { + // Create backup of existing config file before saving new one + if (std::filesystem::exists(m_configFilePath)) { + std::string timestamp = getCurrentTimestamp(); + // Replace colons with dashes to ensure valid filename on all operating systems + std::replace(timestamp.begin(), timestamp.end(), ':', '-'); + std::string backupPath = getBackupFilePath(timestamp); + + try { + std::filesystem::copy_file(m_configFilePath, backupPath); + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Created backup of existing config: %s", + backupPath.c_str()); + } catch (const std::filesystem::filesystem_error& e) { + obs_log(LOG_WARNING, + "[MultiRTMP-ConfigManager] Failed to create backup before saving: %s", + e.what()); + // Continue with save operation even if backup fails + } + } + + if (!writeConfigToFile(m_globalConfig)) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to save configuration to file: %s", + m_configFilePath.c_str()); + return false; + } + + return true; +} + +bool OneSevenLiveMultiRtmpConfigManager::loadConfigurationInternal() { + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Attempting to load configuration from: %s", + m_configFilePath.c_str()); + + if (!std::filesystem::exists(m_configFilePath)) { + obs_log(LOG_INFO, + "[MultiRTMP-ConfigManager] Config file does not exist, creating new empty " + "configuration"); + m_globalConfig = OneSevenLiveMultiRtmpGlobalConfig(); + return saveConfigurationInternal(); + } + + obs_log(LOG_INFO, "[MultiRTMP-ConfigManager] Config file exists, attempting to read"); + + if (!readConfigFromFile(m_globalConfig)) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigManager] Failed to read configuration from file: %s", + m_configFilePath.c_str()); + return false; + } + + obs_log(LOG_INFO, + "[MultiRTMP-ConfigManager] Configuration loaded successfully, %zu streams found", + m_globalConfig.streams.size()); + return true; +} diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.hpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.hpp new file mode 100644 index 0000000..abc11d6 --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpConfigManager.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "OneSevenLiveMultiRtmpModels.hpp" +#include "plugin-support.h" + +/** + * Configuration Manager for Multi-RTMP functionality + * Handles ONLY JSON file operations and configuration CRUD + * Does NOT handle any OBS runtime operations + */ +class OneSevenLiveMultiRtmpConfigManager { + public: + // Callback types for configuration changes + using ConfigChangeCallback = + std::function; + using ConfigDeleteCallback = std::function; + + explicit OneSevenLiveMultiRtmpConfigManager(); + ~OneSevenLiveMultiRtmpConfigManager(); + + // Configuration file operations + bool loadConfiguration(); + bool saveConfiguration(); + bool forceSave(); // Force save configuration immediately (for manual save operations) + std::string getConfigFilePath() const; + + // Stream configuration CRUD operations (JSON only) + bool addStreamConfig(const OneSevenLiveMultiRtmpConfig& config); + bool removeStreamConfig(const std::string& streamId); + bool updateStreamConfig(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); + std::vector getStreamConfigs() const; + OneSevenLiveMultiRtmpConfig getStreamConfig(const std::string& streamId) const; + bool hasStreamConfig(const std::string& streamId) const; + + // Utility methods + std::string generateStreamId() const; + std::string getCurrentTimestamp() const; + size_t getStreamCount() const; + std::vector getStreamIds() const; + + // Callback registration for configuration changes + void setConfigChangeCallback(ConfigChangeCallback callback); + void setConfigDeleteCallback(ConfigDeleteCallback callback); + + // Configuration backup and restore + bool createBackup() const; + bool restoreFromBackup(); + std::vector getAvailableBackups() const; + + private: + // Helper methods + bool ensureConfigDirectoryExists() const; + bool writeConfigToFile(const OneSevenLiveMultiRtmpGlobalConfig& config) const; + bool readConfigFromFile(OneSevenLiveMultiRtmpGlobalConfig& config) const; + std::string getBackupFilePath(const std::string& timestamp) const; + void notifyConfigChange(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); + void notifyConfigDelete(const std::string& streamId); + + // Internal methods + bool saveConfigurationInternal(); + bool loadConfigurationInternal(); + + // Member variables + std::string m_configFilePath; + std::string m_configDirectory; + OneSevenLiveMultiRtmpGlobalConfig m_globalConfig; + + // Callbacks + ConfigChangeCallback m_configChangeCallback; + ConfigDeleteCallback m_configDeleteCallback; + mutable std::mutex m_callbackMutex; + + // Constants + static constexpr const char* CONFIG_FILE_NAME = "17live_multi_rtmp.json"; + static constexpr const char* CONFIG_BACKUP_PREFIX = "17live_multi_rtmp_backup_"; + static constexpr const char* CONFIG_BACKUP_EXTENSION = ".json"; +}; + +// Logging macros for consistent usage +#define MULTI_RTMP_CONFIG_LOG(level, format, ...) \ + obs_log(level, "[MultiRTMP-Config] " format, ##__VA_ARGS__) + +#define MULTI_RTMP_CONFIG_LOG_INFO(format, ...) \ + MULTI_RTMP_CONFIG_LOG(LOG_INFO, format, ##__VA_ARGS__) + +#define MULTI_RTMP_CONFIG_LOG_WARNING(format, ...) \ + MULTI_RTMP_CONFIG_LOG(LOG_WARNING, format, ##__VA_ARGS__) + +#define MULTI_RTMP_CONFIG_LOG_ERROR(format, ...) \ + MULTI_RTMP_CONFIG_LOG(LOG_ERROR, format, ##__VA_ARGS__) + +#define MULTI_RTMP_CONFIG_LOG_DEBUG(format, ...) \ + MULTI_RTMP_CONFIG_LOG(LOG_DEBUG, format, ##__VA_ARGS__) diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.cpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.cpp new file mode 100644 index 0000000..623360c --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.cpp @@ -0,0 +1,801 @@ +#include "OneSevenLiveMultiRtmpManager.hpp" + +#include +#include +#include +#include +#include + +#include "OneSevenLiveConfigManager.hpp" +#include "OneSevenLiveCoreManager.hpp" +#include "streaming/OneSevenLiveStreamManager.hpp" +#include "twitch/OneSevenLiveTwitchAuth.hpp" +#include "youtube/OneSevenLiveYouTubeAuth.hpp" + +// Static member initialization +OneSevenLiveMultiRtmpManager* OneSevenLiveMultiRtmpManager::s_instance = nullptr; +std::mutex OneSevenLiveMultiRtmpManager::s_instanceMutex; + +OneSevenLiveMultiRtmpManager::OneSevenLiveMultiRtmpManager() + : m_configManager(nullptr), m_streamController(nullptr), m_initialized(false) { + obs_log(LOG_INFO, "[MultiRTMP-Manager] Creating MultiRTMP Manager"); +} + +OneSevenLiveMultiRtmpManager::~OneSevenLiveMultiRtmpManager() { + obs_log(LOG_INFO, "[MultiRTMP-Manager] Destroying MultiRTMP Manager"); + shutdown(); +} + +OneSevenLiveMultiRtmpManager* OneSevenLiveMultiRtmpManager::getInstance() { + std::lock_guard lock(s_instanceMutex); + if (!s_instance) { + s_instance = new OneSevenLiveMultiRtmpManager(); + } + return s_instance; +} + +OneSevenLiveMultiRtmpManager* OneSevenLiveMultiRtmpManager::peekInstance() { + std::lock_guard lock(s_instanceMutex); + return s_instance; +} + +void OneSevenLiveMultiRtmpManager::destroyInstance() { + std::lock_guard lock(s_instanceMutex); + if (s_instance) { + delete s_instance; + s_instance = nullptr; + } +} + +bool OneSevenLiveMultiRtmpManager::initialize() { + if (m_initialized) { + obs_log(LOG_WARNING, "[MultiRTMP-Manager] Manager already initialized"); + return true; + } + + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + obs_log(LOG_WARNING, "[MultiRTMP-Manager] Skipping initialize: shutting down"); + return false; + } + + obs_log(LOG_INFO, "[MultiRTMP-Manager] Initializing MultiRTMP Manager"); + + try { + // Create configuration manager + m_configManager = std::make_unique(); + if (!m_configManager) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to create configuration manager"); + return false; + } + + // Create stream controller + m_streamController = std::make_unique(); + if (!m_streamController) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to create stream controller"); + return false; + } + + // Setup callbacks between components + setupCallbacks(); + + // Mark as initialized before loading configuration + m_initialized = true; + + // Load existing configuration + obs_log(LOG_INFO, "[MultiRTMP-Manager] Attempting to load configuration..."); + if (!loadConfiguration()) { + obs_log(LOG_WARNING, + "[MultiRTMP-Manager] Failed to load configuration, starting with empty config"); + obs_log(LOG_INFO, "[MultiRTMP-Manager] Config manager state: %s", + m_configManager ? "valid" : "null"); + } else { + obs_log(LOG_INFO, "[MultiRTMP-Manager] Configuration loaded successfully"); + } + obs_log(LOG_INFO, "[MultiRTMP-Manager] MultiRTMP Manager initialized successfully"); + return true; + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Exception during initialization: %s", e.what()); + m_initialized = false; // Reset initialization flag on failure + return false; + } +} + +void OneSevenLiveMultiRtmpManager::shutdown() { + if (!m_initialized) { + return; + } + + obs_log(LOG_INFO, "[MultiRTMP-Manager] Shutting down MultiRTMP Manager"); + + try { + auto& core = OneSevenLiveCoreManager::getInstance(); + + if (m_streamController) { + if (QThread::currentThread() == core.thread()) { + m_streamController->beginShutdown(); + m_streamController->stopStatsMonitoring(); + (void) m_streamController->stopAllOutputs(); + m_streamController->destroyAllOutputs(); + } else { + QMetaObject::invokeMethod( + &core, + [this]() { + if (!m_streamController) + return; + m_streamController->beginShutdown(); + m_streamController->stopStatsMonitoring(); + (void) m_streamController->stopAllOutputs(); + m_streamController->destroyAllOutputs(); + }, + Qt::BlockingQueuedConnection); + } + } + + if (!OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + saveConfiguration(); + } else { + obs_log(LOG_INFO, "[MultiRTMP-Manager] Skipping save during shutdown"); + } + + // Cleanup callbacks + cleanupCallbacks(); + + // Reset components + m_streamController.reset(); + m_configManager.reset(); + + m_initialized = false; + obs_log(LOG_INFO, "[MultiRTMP-Manager] MultiRTMP Manager shutdown complete"); + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Exception during shutdown: %s", e.what()); + } +} + +// Configuration operations +bool OneSevenLiveMultiRtmpManager::addStreamConfig(const OneSevenLiveMultiRtmpConfig& config) { + obs_log(LOG_INFO, "[MultiRTMP-Manager] Adding stream config: %s", config.streamName.c_str()); + + if (!m_initialized) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + + if (!m_configManager) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Config manager is null"); + return false; + } + + bool result = m_configManager->addStreamConfig(config); + + if (!result) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Config manager failed to add stream config"); + return false; + } + + // Save configuration to file immediately after adding + obs_log(LOG_INFO, "[MultiRTMP-Manager] Saving configuration after adding stream: %s", + config.streamName.c_str()); + if (!saveConfiguration()) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to save configuration after adding stream"); + // Note: We don't return false here as the config was added to memory successfully + } else { + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Configuration saved successfully after adding stream"); + } + + return result; +} + +bool OneSevenLiveMultiRtmpManager::removeStreamConfig(const std::string& streamId) { + if (!m_initialized || !m_configManager) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + + // Stop stream if it's running + if (isStreamActive(streamId)) { + stopStream(streamId); + } + + // Destroy output if it exists + destroyStreamOutput(streamId); + + OneSevenLiveMultiRtmpConfig cfg = m_configManager->getStreamConfig(streamId); + if (!cfg.id.empty()) { + OneSevenLiveConfigManager* cm = OneSevenLiveCoreManager::getInstance().getConfigManager(); + const std::string platform = cfg.streamName; + if (cm && cm->initialize()) { + if (platform == "YouTube") { + // (void) cm->clearYouTubeAccessToken(); + // (void) cm->clearYouTubeRefreshToken(); + obs_log(LOG_INFO, "[MultiRTMP-Manager] YouTube tokens retention on delete: %s", + streamId.c_str()); + } else if (platform == "Twitch") { + (void) cm->clearTwitchTokens(); + (void) cm->clearTwitchUserInfo(); + obs_log(LOG_INFO, "[MultiRTMP-Manager] Cleared Twitch tokens on delete: %s", + streamId.c_str()); + } + } + + auto& core = OneSevenLiveCoreManager::getInstance(); + if (QThread::currentThread() == core.thread()) { + if (platform == "YouTube") { + // if (core.getYouTubeAuth()) { + // core.getYouTubeAuth()->clearToken(); + // } + // core.stopYouTubeChatPolling(); + // core.destroyYouTubeChatClient(); + obs_log( + LOG_INFO, + "[MultiRTMP-Manager] Skipped YouTube interruption due to temporary disable: %s", + streamId.c_str()); + } else if (platform == "Twitch") { + if (core.getTwitchAuth()) { + core.getTwitchAuth()->clearTokens(); + } + core.disconnectTwitchChatClient(); + core.destroyTwitchChatClient(); + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Interrupted Twitch connections and cleared in-memory " + "tokens: %s", + streamId.c_str()); + } + } else { + QMetaObject::invokeMethod( + &core, + [platform, streamId, &core]() { + if (platform == std::string("YouTube")) { + // if (core.getYouTubeAuth()) { + // core.getYouTubeAuth()->clearToken(); + // } + // core.stopYouTubeChatPolling(); + // core.destroyYouTubeChatClient(); + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Skipped YouTube interruption due to temporary " + "disable: %s", + streamId.c_str()); + } else if (platform == std::string("Twitch")) { + if (core.getTwitchAuth()) { + core.getTwitchAuth()->clearTokens(); + } + core.disconnectTwitchChatClient(); + core.destroyTwitchChatClient(); + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Interrupted Twitch connections and cleared " + "in-memory tokens: %s", + streamId.c_str()); + } + }, + Qt::BlockingQueuedConnection); + } + } + + bool result = m_configManager->removeStreamConfig(streamId); + + if (result) { + // Save configuration to file immediately after removing + obs_log(LOG_INFO, "[MultiRTMP-Manager] Saving configuration after removing stream: %s", + streamId.c_str()); + if (!saveConfiguration()) { + obs_log(LOG_ERROR, + "[MultiRTMP-Manager] Failed to save configuration after removing stream"); + // Note: We don't return false here as the config was removed from memory successfully + } else { + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Configuration saved successfully after removing stream"); + } + } + + return result; +} + +bool OneSevenLiveMultiRtmpManager::updateStreamConfig(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config) { + if (!m_initialized || !m_configManager) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + + // If stream is active, we need to restart it with new config + bool wasActive = isStreamActive(streamId); + if (wasActive) { + stopStream(streamId); + destroyStreamOutput(streamId); + } + + bool result = m_configManager->updateStreamConfig(streamId, config); + + if (result) { + // Save configuration to file immediately after updating + obs_log(LOG_INFO, "[MultiRTMP-Manager] Saving configuration after updating stream: %s", + streamId.c_str()); + if (!saveConfiguration()) { + obs_log(LOG_ERROR, + "[MultiRTMP-Manager] Failed to save configuration after updating stream"); + // Note: We don't return false here as the config was updated in memory successfully + } else { + obs_log(LOG_INFO, + "[MultiRTMP-Manager] Configuration saved successfully after updating stream"); + } + } + + // Restart if it was active + if (result && wasActive) { + createStreamOutput(streamId); + startStream(streamId); + } + + return result; +} + +std::vector OneSevenLiveMultiRtmpManager::getAllStreamConfigs() const { + if (!m_configManager) { + return {}; + } + return m_configManager->getStreamConfigs(); +} + +OneSevenLiveMultiRtmpConfig OneSevenLiveMultiRtmpManager::getStreamConfig( + const std::string& streamId) const { + if (!m_initialized || !m_configManager) { + return {}; + } + return m_configManager->getStreamConfig(streamId); +} + +bool OneSevenLiveMultiRtmpManager::hasStreamConfig(const std::string& streamId) const { + if (!m_initialized || !m_configManager) { + return false; + } + return m_configManager->hasStreamConfig(streamId); +} + +// Runtime operations +bool OneSevenLiveMultiRtmpManager::startStream(const std::string& streamId) { + if (!m_initialized || !m_streamController) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + + if (!hasStreamConfig(streamId)) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Stream configuration not found: %s", + streamId.c_str()); + return false; + } + + QTimer::singleShot(0, [this, streamId]() { + if (!ensureStreamOutput(streamId)) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to create stream output: %s", + streamId.c_str()); + return; + } + if (!m_streamController->startOutput(streamId)) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to start stream: %s", streamId.c_str()); + } + }); + + return true; +} + +bool OneSevenLiveMultiRtmpManager::stopStream(const std::string& streamId) { + if (!m_initialized || !m_streamController) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + auto& core = OneSevenLiveCoreManager::getInstance(); + bool stopped = false; + if (QThread::currentThread() == core.thread()) { + stopped = m_streamController->stopOutput(streamId); + } else { + QMetaObject::invokeMethod( + &core, + [this, &streamId, &stopped]() { stopped = m_streamController->stopOutput(streamId); }, + Qt::BlockingQueuedConnection); + } + + OneSevenLiveMultiRtmpConfig cfg = getStreamConfig(streamId); + if (!cfg.id.empty() && cfg.streamName == std::string("YouTube")) { + QMetaObject::invokeMethod( + &core, + []() { + OneSevenLiveCoreManager& c = OneSevenLiveCoreManager::getInstance(); + c.enqueueOrBroadcastChatEvent(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + }, + Qt::QueuedConnection); + } + return stopped; +} + +bool OneSevenLiveMultiRtmpManager::startAllStreams() { + if (!m_initialized || !m_streamController) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + + auto configs = getAllStreamConfigs(); + for (const auto& config : configs) { + (void) startStream(config.id); + } + + return true; +} + +bool OneSevenLiveMultiRtmpManager::stopAllStreams() { + if (!m_initialized || !m_streamController) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Manager not initialized"); + return false; + } + auto& core = OneSevenLiveCoreManager::getInstance(); + bool stopped = false; + if (QThread::currentThread() == core.thread()) { + stopped = m_streamController->stopAllOutputs(); + } else { + QMetaObject::invokeMethod( + &core, [this, &stopped]() { stopped = m_streamController->stopAllOutputs(); }, + Qt::BlockingQueuedConnection); + } + + auto configs = getAllStreamConfigs(); + bool hadYouTube = std::any_of(configs.begin(), configs.end(), [](const auto& c) { + return c.streamName == std::string("YouTube"); + }); + if (hadYouTube) { + QMetaObject::invokeMethod( + &core, + []() { + OneSevenLiveCoreManager& c = OneSevenLiveCoreManager::getInstance(); + c.enqueueOrBroadcastChatEvent(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + }, + Qt::QueuedConnection); + } + return stopped; +} + +// Status and statistics +OneSevenLiveMultiRtmpStreamStatus OneSevenLiveMultiRtmpManager::getStreamStatus( + const std::string& streamId) const { + if (!m_initialized || !m_streamController) { + return {}; + } + return m_streamController->getStreamStatus(streamId); +} + +OneSevenLiveMultiRtmpStreamStatus OneSevenLiveMultiRtmpManager::getStreamStatusByName( + const std::string& streamName) const { + OneSevenLiveMultiRtmpStreamStatus status; + status.state = OneSevenLiveMultiRtmpStreamStatus::STOPPED; + if (!m_initialized || !m_streamController || !m_configManager) + return status; + auto configs = m_configManager->getStreamConfigs(); + for (const auto& cfg : configs) { + if (cfg.streamName == streamName) { + return m_streamController->getStreamStatus(cfg.id); + } + } + return status; +} + +bool OneSevenLiveMultiRtmpManager::isPlatformStreaming(const std::string& streamName) const { + auto st = getStreamStatusByName(streamName); + return st.state == OneSevenLiveMultiRtmpStreamStatus::STREAMING; +} + +OneSevenLiveMultiRtmpStreamStats OneSevenLiveMultiRtmpManager::getStreamStats( + const std::string& streamId) const { + if (!m_initialized || !m_streamController) { + return {}; + } + return m_streamController->getStreamStats(streamId); +} + +std::vector OneSevenLiveMultiRtmpManager::getActiveStreamIds() const { + if (!m_initialized || !m_streamController) { + return {}; + } + return m_streamController->getActiveStreamIds(); +} + +std::vector OneSevenLiveMultiRtmpManager::getAllStreamIds() const { + if (!m_initialized || !m_configManager) { + return {}; + } + + auto configs = m_configManager->getStreamConfigs(); + std::vector ids; + ids.reserve(configs.size()); + + for (const auto& config : configs) { + ids.push_back(config.id); + } + + return ids; +} + +// Utility methods +std::string OneSevenLiveMultiRtmpManager::generateStreamId() const { + if (!m_initialized || !m_configManager) { + return ""; + } + return m_configManager->generateStreamId(); +} + +size_t OneSevenLiveMultiRtmpManager::getStreamCount() const { + if (!m_initialized || !m_configManager) { + return 0; + } + return m_configManager->getStreamCount(); +} + +// Configuration file operations +bool OneSevenLiveMultiRtmpManager::saveConfiguration() { + if (!m_initialized || !m_configManager) { + return false; + } + return m_configManager->saveConfiguration(); +} + +bool OneSevenLiveMultiRtmpManager::loadConfiguration() { + if (!m_initialized || !m_configManager) { + obs_log( + LOG_ERROR, + "[MultiRTMP-Manager] loadConfiguration() failed - initialized: %s, configManager: %s", + m_initialized ? "true" : "false", m_configManager ? "valid" : "null"); + return false; + } + + obs_log(LOG_INFO, "[MultiRTMP-Manager] Calling configManager->loadConfiguration()"); + bool result = m_configManager->loadConfiguration(); + obs_log(LOG_INFO, "[MultiRTMP-Manager] configManager->loadConfiguration() returned: %s", + result ? "true" : "false"); + return result; +} + +bool OneSevenLiveMultiRtmpManager::createConfigBackup() { + if (!m_initialized || !m_configManager) { + return false; + } + return m_configManager->createBackup(); +} + +bool OneSevenLiveMultiRtmpManager::restoreFromBackup() { + if (!m_initialized || !m_configManager) { + return false; + } + return m_configManager->restoreFromBackup(); +} + +// Callback registration +void OneSevenLiveMultiRtmpManager::setStreamStatusCallback(StreamStatusCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_statusCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpManager::setStreamStatsCallback(StreamStatsCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_statsCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpManager::setConfigChangeCallback(ConfigChangeCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_configChangeCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpManager::setConfigDeleteCallback(ConfigDeleteCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_configDeleteCallback = std::move(callback); +} + +// Stream lifecycle management +bool OneSevenLiveMultiRtmpManager::createStreamOutput(const std::string& streamId) { + if (!m_initialized || !m_streamController) { + return false; + } + auto config = getStreamConfig(streamId); + if (config.id.empty()) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Stream configuration not found: %s", + streamId.c_str()); + return false; + } + + return m_streamController->createOutput(streamId, config); +} + +bool OneSevenLiveMultiRtmpManager::destroyStreamOutput(const std::string& streamId) { + if (!m_initialized || !m_streamController) { + return false; + } + auto& core = OneSevenLiveCoreManager::getInstance(); + if (QThread::currentThread() == core.thread()) { + return m_streamController->destroyOutput(streamId); + } else { + bool result = false; + QMetaObject::invokeMethod( + &core, + [this, &streamId, &result]() { result = m_streamController->destroyOutput(streamId); }, + Qt::BlockingQueuedConnection); + return result; + } +} + +void OneSevenLiveMultiRtmpManager::destroyAllStreamOutputs() { + if (!m_initialized || !m_streamController) { + return; + } + auto& core = OneSevenLiveCoreManager::getInstance(); + if (QThread::currentThread() == core.thread()) { + m_streamController->destroyAllOutputs(); + } else { + QMetaObject::invokeMethod( + &core, [this]() { m_streamController->destroyAllOutputs(); }, + Qt::BlockingQueuedConnection); + } +} + +// Bulk operations with synchronization +bool OneSevenLiveMultiRtmpManager::startAllStreamsWithSync() { + if (!m_initialized || !m_streamController) { + return false; + } + + auto configs = getAllStreamConfigs(); + + // Create all outputs first + for (const auto& config : configs) { + if (!ensureStreamOutput(config.id)) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] Failed to create output for stream: %s", + config.id.c_str()); + return false; + } + } + + // Start all streams with synchronization + return m_streamController->startAllOutputs(); +} + +bool OneSevenLiveMultiRtmpManager::stopAllStreamsWithSync() { + if (!m_initialized || !m_streamController) { + return false; + } + return m_streamController->stopAllOutputs(); +} + +// Statistics monitoring control +void OneSevenLiveMultiRtmpManager::startStatsMonitoring() { + if (!m_initialized || !m_streamController) { + return; + } + m_streamController->startStatsMonitoring(); +} + +void OneSevenLiveMultiRtmpManager::stopStatsMonitoring() { + if (!m_initialized || !m_streamController) { + return; + } + m_streamController->stopStatsMonitoring(); +} + +// State management +bool OneSevenLiveMultiRtmpManager::isStreamActive(const std::string& streamId) const { + if (!m_initialized || !m_streamController) { + return false; + } + return m_streamController->isStreamActive(streamId); +} + +bool OneSevenLiveMultiRtmpManager::hasStreamOutput(const std::string& streamId) const { + if (!m_initialized || !m_streamController) { + return false; + } + return m_streamController->hasOutput(streamId); +} + +obs_output_t* OneSevenLiveMultiRtmpManager::getStreamOutput(const std::string& streamId) const { + if (!m_initialized || !m_streamController) { + return nullptr; + } + return m_streamController->getStreamOutput(streamId); +} + +// Private methods +void OneSevenLiveMultiRtmpManager::onConfigChanged(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config) { + ConfigChangeCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_configChangeCallback; + } + if (cb) { + cb(streamId, config); + } +} + +void OneSevenLiveMultiRtmpManager::onConfigDeleted(const std::string& streamId) { + ConfigDeleteCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_configDeleteCallback; + } + if (cb) { + cb(streamId); + } +} + +void OneSevenLiveMultiRtmpManager::onStreamStatusChanged( + const std::string& streamId, const OneSevenLiveMultiRtmpStreamStatus& status) { + StreamStatusCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_statusCallback; + } + if (cb) { + cb(streamId, status); + } +} + +void OneSevenLiveMultiRtmpManager::onStreamStatsUpdated( + const std::string& streamId, const OneSevenLiveMultiRtmpStreamStats& stats) { + StreamStatsCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_statsCallback; + } + if (cb) { + cb(streamId, stats); + } +} + +void OneSevenLiveMultiRtmpManager::setupCallbacks() { + if (m_configManager) { + m_configManager->setConfigChangeCallback( + [this](const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + onConfigChanged(streamId, config); + }); + + m_configManager->setConfigDeleteCallback( + [this](const std::string& streamId) { onConfigDeleted(streamId); }); + } + + if (m_streamController) { + m_streamController->setStreamStatusCallback( + [this](const std::string& streamId, const OneSevenLiveMultiRtmpStreamStatus& status) { + onStreamStatusChanged(streamId, status); + }); + + m_streamController->setStreamStatsCallback( + [this](const std::string& streamId, const OneSevenLiveMultiRtmpStreamStats& stats) { + onStreamStatsUpdated(streamId, stats); + }); + } +} + +void OneSevenLiveMultiRtmpManager::cleanupCallbacks() { + if (m_configManager) { + m_configManager->setConfigChangeCallback(nullptr); + m_configManager->setConfigDeleteCallback(nullptr); + } + + if (m_streamController) { + m_streamController->setStreamStatusCallback(nullptr); + m_streamController->setStreamStatsCallback(nullptr); + } + + { + std::lock_guard lock(m_callbackMutex); + m_statusCallback = nullptr; + m_statsCallback = nullptr; + m_configChangeCallback = nullptr; + m_configDeleteCallback = nullptr; + } +} + +bool OneSevenLiveMultiRtmpManager::ensureStreamOutput(const std::string& streamId) { + if (hasStreamOutput(streamId)) { + return true; + } + return createStreamOutput(streamId); +} diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.hpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.hpp new file mode 100644 index 0000000..dbf1612 --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpManager.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include + +#include +#include +#include + +#include "OneSevenLiveMultiRtmpConfigManager.hpp" +#include "OneSevenLiveMultiRtmpModels.hpp" +#include "OneSevenLiveMultiRtmpStreamController.hpp" +#include "plugin-support.h" + +/** + * Main Manager for Multi-RTMP functionality + * Coordinates between configuration management and OBS stream operations + * Provides a unified interface for the UI layer + */ +class OneSevenLiveMultiRtmpManager { + public: + // Callback types for UI notifications + using StreamStatusCallback = std::function; + using StreamStatsCallback = std::function; + using ConfigChangeCallback = + std::function; + using ConfigDeleteCallback = std::function; + + OneSevenLiveMultiRtmpManager(); + ~OneSevenLiveMultiRtmpManager(); + + // Singleton access + static OneSevenLiveMultiRtmpManager* getInstance(); + static OneSevenLiveMultiRtmpManager* peekInstance(); + static void destroyInstance(); + + // Initialization and cleanup + bool initialize(); + void shutdown(); + + // Configuration operations (delegates to ConfigManager) + bool addStreamConfig(const OneSevenLiveMultiRtmpConfig& config); + bool removeStreamConfig(const std::string& streamId); + bool updateStreamConfig(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); + std::vector getAllStreamConfigs() const; + OneSevenLiveMultiRtmpConfig getStreamConfig(const std::string& streamId) const; + bool hasStreamConfig(const std::string& streamId) const; + + // Runtime operations (delegates to StreamController) + bool startStream(const std::string& streamId); + bool stopStream(const std::string& streamId); + bool startAllStreams(); + bool stopAllStreams(); + + // Status and statistics monitoring + OneSevenLiveMultiRtmpStreamStatus getStreamStatus(const std::string& streamId) const; + OneSevenLiveMultiRtmpStreamStatus getStreamStatusByName(const std::string& streamName) const; + bool isPlatformStreaming(const std::string& streamName) const; + OneSevenLiveMultiRtmpStreamStats getStreamStats(const std::string& streamId) const; + std::vector getActiveStreamIds() const; + std::vector getAllStreamIds() const; + + // Utility methods + std::string generateStreamId() const; + size_t getStreamCount() const; + + // Configuration file operations + bool saveConfiguration(); + bool loadConfiguration(); + bool createConfigBackup(); + bool restoreFromBackup(); + + // Callback registration for UI updates + void setStreamStatusCallback(StreamStatusCallback callback); + void setStreamStatsCallback(StreamStatsCallback callback); + void setConfigChangeCallback(ConfigChangeCallback callback); + void setConfigDeleteCallback(ConfigDeleteCallback callback); + + // Stream lifecycle management + bool createStreamOutput(const std::string& streamId); + bool destroyStreamOutput(const std::string& streamId); + void destroyAllStreamOutputs(); + + // Bulk operations with synchronization + bool startAllStreamsWithSync(); + bool stopAllStreamsWithSync(); + + // Statistics monitoring control + void startStatsMonitoring(); + void stopStatsMonitoring(); + + // State management + bool isInitialized() const { + return m_initialized; + } + + bool isStreamActive(const std::string& streamId) const; + bool hasStreamOutput(const std::string& streamId) const; + + // Get stream output for real-time statistics + obs_output_t* getStreamOutput(const std::string& streamId) const; + + private: + // Internal callback handlers + void onConfigChanged(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); + void onConfigDeleted(const std::string& streamId); + void onStreamStatusChanged(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStatus& status); + void onStreamStatsUpdated(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStats& stats); + + // Helper methods + void setupCallbacks(); + void cleanupCallbacks(); + bool ensureStreamOutput(const std::string& streamId); + + // Member variables + std::unique_ptr m_configManager; + std::unique_ptr m_streamController; + + // UI callbacks + StreamStatusCallback m_statusCallback; + StreamStatsCallback m_statsCallback; + ConfigChangeCallback m_configChangeCallback; + ConfigDeleteCallback m_configDeleteCallback; + + // State + bool m_initialized = false; + + // Synchronization for callback assignment/invocation + mutable std::mutex m_callbackMutex; + + // Singleton instance + static OneSevenLiveMultiRtmpManager* s_instance; + static std::mutex s_instanceMutex; +}; diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.cpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.cpp new file mode 100644 index 0000000..2af6610 --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.cpp @@ -0,0 +1,300 @@ +#include "OneSevenLiveMultiRtmpModels.hpp" + +#include +#include +#include +#include + +static OneSevenLiveProtocol s_protocolList[] = {{"rtmp", "RTMP", "rtmp_output", "rtmp_common"}}; + +// OneSevenLiveMultiRtmpVideoConfig implementation +void OneSevenLiveMultiRtmpVideoConfig::to_json(nlohmann::json& j) const { + j = nlohmann::json{{"encoderId", encoderId}, + {"fpsDenominator", fpsDenominator}, + {"encoderSettings", encoderSettings}, + {"outputScene", outputScene}, + {"resolution", resolution}}; +} + +void OneSevenLiveMultiRtmpVideoConfig::from_json(const nlohmann::json& j) { + if (j.contains("encoderId")) { + j.at("encoderId").get_to(encoderId); + } + if (j.contains("fpsDenominator")) { + j.at("fpsDenominator").get_to(fpsDenominator); + } + if (j.contains("encoderSettings")) { + j.at("encoderSettings").get_to(encoderSettings); + } + if (j.contains("outputScene")) { + j.at("outputScene").get_to(outputScene); + } + if (j.contains("resolution")) { + j.at("resolution").get_to(resolution); + } +} + +// AudioTrackConfig JSON serialization +void to_json(nlohmann::json& j, const AudioTrackConfig& config) { + j = nlohmann::json{{"mixer_track", config.mixer_track}, {"output_track", config.output_track}}; +} + +void from_json(const nlohmann::json& j, AudioTrackConfig& config) { + j.at("mixer_track").get_to(config.mixer_track); + j.at("output_track").get_to(config.output_track); +} + +// OneSevenLiveMultiRtmpAudioConfig implementation +void OneSevenLiveMultiRtmpAudioConfig::to_json(nlohmann::json& j) const { + j = nlohmann::json{{"encoderId", encoderId}, + {"encoderSettings", encoderSettings}, + {"mixerId", mixerId}, + {"audioTracks", audioTracks}}; +} + +void OneSevenLiveMultiRtmpAudioConfig::from_json(const nlohmann::json& j) { + if (j.contains("encoderId")) { + j.at("encoderId").get_to(encoderId); + } + if (j.contains("encoderSettings")) { + j.at("encoderSettings").get_to(encoderSettings); + } + if (j.contains("mixerId")) { + j.at("mixerId").get_to(mixerId); + } + if (j.contains("audioTracks")) { + j.at("audioTracks").get_to(audioTracks); + } +} + +// OneSevenLiveMultiRtmpConfig implementation +void OneSevenLiveMultiRtmpConfig::to_json(nlohmann::json& j) const { + j = nlohmann::json{{"id", id}, + {"streamName", streamName}, + {"protocol", protocol}, + {"serviceSettings", serviceSettings}, + {"outputSettings", outputSettings}, + {"videoConfig", videoConfig.has_value() ? nlohmann::json(videoConfig.value()) + : nlohmann::json(nullptr)}, + {"audioConfig", audioConfig.has_value() ? nlohmann::json(audioConfig.value()) + : nlohmann::json(nullptr)}}; +} + +void OneSevenLiveMultiRtmpConfig::from_json(const nlohmann::json& j) { + j.at("id").get_to(id); + if (j.contains("streamName")) { + j.at("streamName").get_to(streamName); + } + if (j.contains("protocol")) { + j.at("protocol").get_to(protocol); + } + if (j.contains("serviceSettings")) { + j.at("serviceSettings").get_to(serviceSettings); + } + if (j.contains("outputSettings")) { + j.at("outputSettings").get_to(outputSettings); + } + if (j.contains("videoConfig")) { + if (j["videoConfig"].is_null()) { + videoConfig.reset(); + } else { + videoConfig = j["videoConfig"].get(); + } + } + if (j.contains("audioConfig")) { + if (j["audioConfig"].is_null()) { + audioConfig.reset(); + } else { + audioConfig = j["audioConfig"].get(); + } + } +} + +// OneSevenLiveMultiRtmpStreamStatus implementation +std::string OneSevenLiveMultiRtmpStreamStatus::getStateString() const { + switch (state) { + case STOPPED: + return "Stopped"; + case CONNECTING: + return "Connecting"; + case STREAMING: + return "Streaming"; + case RECONNECTING: + return "Reconnecting"; + case ERROR_STATE: + return "Error"; + default: + return "Unknown"; + } +} + +bool OneSevenLiveMultiRtmpStreamStatus::isActive() const { + return state == CONNECTING || state == STREAMING || state == RECONNECTING; +} + +// OneSevenLiveMultiRtmpStreamStats implementation +std::string OneSevenLiveMultiRtmpStreamStats::getDurationString() const { + auto totalSeconds = static_cast(duration.count()); + int hours = totalSeconds / 3600; + int minutes = (totalSeconds % 3600) / 60; + int seconds = totalSeconds % 60; + + std::ostringstream oss; + oss << std::setfill('0') << std::setw(2) << hours << ":" << std::setfill('0') << std::setw(2) + << minutes << ":" << std::setfill('0') << std::setw(2) << seconds; + return oss.str(); +} + +double OneSevenLiveMultiRtmpStreamStats::getDroppedFramePercentage() const { + if (totalFrames == 0) { + return 0.0; + } + return (static_cast(droppedFrames) / totalFrames) * 100.0; +} + +// OneSevenLiveMultiRtmpGlobalConfig implementation +void OneSevenLiveMultiRtmpGlobalConfig::to_json(nlohmann::json& j) const { + j = nlohmann::json{{"streams", streams}}; +} + +void OneSevenLiveMultiRtmpGlobalConfig::from_json(const nlohmann::json& j) { + if (j.contains("streams")) { + j.at("streams").get_to(streams); + } +} + +OneSevenLiveMultiRtmpConfig* OneSevenLiveMultiRtmpGlobalConfig::findStream( + const std::string& streamId) { + auto it = std::find_if( + streams.begin(), streams.end(), + [&streamId](const OneSevenLiveMultiRtmpConfig& config) { return config.id == streamId; }); + return (it != streams.end()) ? &(*it) : nullptr; +} + +const OneSevenLiveMultiRtmpConfig* OneSevenLiveMultiRtmpGlobalConfig::findStream( + const std::string& streamId) const { + auto it = std::find_if( + streams.begin(), streams.end(), + [&streamId](const OneSevenLiveMultiRtmpConfig& config) { return config.id == streamId; }); + return (it != streams.end()) ? &(*it) : nullptr; +} + +bool OneSevenLiveMultiRtmpGlobalConfig::removeStream(const std::string& streamId) { + auto it = std::remove_if( + streams.begin(), streams.end(), + [&streamId](const OneSevenLiveMultiRtmpConfig& config) { return config.id == streamId; }); + if (it != streams.end()) { + streams.erase(it); + return true; + } + return false; +} + +void OneSevenLiveMultiRtmpGlobalConfig::addStream(const OneSevenLiveMultiRtmpConfig& config) { + streams.push_back(config); +} + +void OneSevenLiveMultiRtmpGlobalConfig::updateStream(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config) { + auto* existingConfig = findStream(streamId); + if (existingConfig) { + *existingConfig = config; + } +} + +// Global JSON serialization functions +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpVideoConfig& config) { + config.to_json(j); +} + +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpVideoConfig& config) { + config.from_json(j); +} + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpAudioConfig& config) { + config.to_json(j); +} + +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpAudioConfig& config) { + config.from_json(j); +} + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpConfig& config) { + config.to_json(j); +} + +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpConfig& config) { + config.from_json(j); +} + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpGlobalConfig& config) { + config.to_json(j); +} + +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpGlobalConfig& config) { + config.from_json(j); +} + +// Protocol helper functions implementation +const OneSevenLiveProtocol* getProtocolList() { + return s_protocolList; +} + +size_t getProtocolCount() { + return sizeof(s_protocolList) / sizeof(s_protocolList[0]); +} + +const OneSevenLiveProtocol* findProtocol(const std::string& protocol) { + const OneSevenLiveProtocol* protocols = getProtocolList(); + size_t count = getProtocolCount(); + + for (size_t i = 0; i < count; ++i) { + if (protocols[i].protocol == protocol) { + return &protocols[i]; + } + } + + return nullptr; // Protocol not found +} + +std::string get_protocol_from_settings(const nlohmann::json& j) { + if (j.is_null()) + return ""; + try { + if (j.contains("server") && j["server"].is_string()) { + std::string url = j["server"].get(); + if (url.rfind("rtmps://", 0) == 0) + return "RTMPS"; + return "RTMP"; + } + if (j.contains("protocol") && j["protocol"].is_string()) { + return j["protocol"].get(); + } + } catch (...) { + } + return ""; +} + +std::string get_url_from_settings(const nlohmann::json& j) { + if (j.is_null()) + return ""; + try { + if (j.contains("server") && j["server"].is_string()) { + return j["server"].get(); + } + } catch (...) { + } + return ""; +} + +std::string get_key_from_settings(const nlohmann::json& j) { + if (j.is_null()) + return ""; + try { + if (j.contains("key") && j["key"].is_string()) { + return j["key"].get(); + } + } catch (...) { + } + return ""; +} diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.hpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.hpp new file mode 100644 index 0000000..faf6e6d --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpModels.hpp @@ -0,0 +1,150 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Forward declarations +struct OneSevenLiveMultiRtmpVideoConfig; +struct OneSevenLiveMultiRtmpAudioConfig; +struct OneSevenLiveMultiRtmpConfig; +struct OneSevenLiveMultiRtmpStreamStatus; +struct OneSevenLiveMultiRtmpStreamStats; + +struct OneSevenLiveProtocol { + const char* protocol; + const char* label; + const char* outputId; + const char* serviceId; +}; + +/** + * Video configuration for RTMP stream + */ +struct OneSevenLiveMultiRtmpVideoConfig { + std::string encoderId; + int fpsDenominator = 1; + nlohmann::json encoderSettings; + + std::string outputScene; + std::string resolution; + + // JSON serialization + void to_json(nlohmann::json& j) const; + void from_json(const nlohmann::json& j); +}; + +/** + * Audio configuration for RTMP stream + */ + +struct AudioTrackConfig { + int mixer_track = 0; + int output_track = 0; +}; + +struct OneSevenLiveMultiRtmpAudioConfig { + std::string encoderId; + nlohmann::json encoderSettings; + int mixerId = 0; + std::vector audioTracks; + + // JSON serialization + void to_json(nlohmann::json& j) const; + void from_json(const nlohmann::json& j); +}; + +/** + * Complete configuration for a single RTMP stream + */ +struct OneSevenLiveMultiRtmpConfig { + std::string id; + std::string streamName; + std::string protocol = "rtmp"; + + nlohmann::json serviceSettings; + nlohmann::json outputSettings; + + std::optional videoConfig; + std::optional audioConfig; + + // JSON serialization + void to_json(nlohmann::json& j) const; + void from_json(const nlohmann::json& j); +}; + +/** + * Stream status information + */ +struct OneSevenLiveMultiRtmpStreamStatus { + enum State { STOPPED, CONNECTING, STREAMING, RECONNECTING, ERROR_STATE }; + + std::string id; + State state = STOPPED; + std::string errorMessage; + std::chrono::steady_clock::time_point startTime; + + // Helper methods + std::string getStateString() const; + bool isActive() const; +}; + +/** + * Stream statistics information + */ +struct OneSevenLiveMultiRtmpStreamStats { + std::string id; + std::chrono::duration duration; + double currentBitrate = 0.0; + double averageBitrate = 0.0; + int currentFPS = 0; + int droppedFrames = 0; + int totalFrames = 0; + double cpuUsage = 0.0; + + // Helper methods + std::string getDurationString() const; + double getDroppedFramePercentage() const; +}; + +/** + * Multi-RTMP configuration container + */ +struct OneSevenLiveMultiRtmpGlobalConfig { + std::vector streams; + + // JSON serialization + void to_json(nlohmann::json& j) const; + void from_json(const nlohmann::json& j); + + // Helper methods + OneSevenLiveMultiRtmpConfig* findStream(const std::string& streamId); + const OneSevenLiveMultiRtmpConfig* findStream(const std::string& streamId) const; + bool removeStream(const std::string& streamId); + void addStream(const OneSevenLiveMultiRtmpConfig& config); + void updateStream(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); +}; + +// JSON serialization helpers +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpVideoConfig& config); +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpVideoConfig& config); + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpAudioConfig& config); +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpAudioConfig& config); + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpConfig& config); +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpConfig& config); + +void to_json(nlohmann::json& j, const OneSevenLiveMultiRtmpGlobalConfig& config); +void from_json(const nlohmann::json& j, OneSevenLiveMultiRtmpGlobalConfig& config); + +// Protocol helper functions +const OneSevenLiveProtocol* getProtocolList(); +size_t getProtocolCount(); +const OneSevenLiveProtocol* findProtocol(const std::string& protocol); + +std::string get_protocol_from_settings(const nlohmann::json& j); +std::string get_url_from_settings(const nlohmann::json& j); +std::string get_key_from_settings(const nlohmann::json& j); diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.cpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.cpp new file mode 100644 index 0000000..bf3eeb0 --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.cpp @@ -0,0 +1,1381 @@ +#include "OneSevenLiveMultiRtmpStreamController.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" +#include "plugin-support.h" +#include "streaming/OneSevenLiveStreamManager.hpp" +#include "twitch/OneSevenLiveTwitchAuth.hpp" +#include "twitch/OneSevenLiveTwitchClient.hpp" +#include "utility/Common.hpp" +#include "utility/RemoteTextThread.hpp" +#include "youtube/OneSevenLiveYouTubeAuth.hpp" +#include "youtube/OneSevenLiveYouTubeChatClient.hpp" +#include "youtube/OneSevenLiveYouTubeClient.hpp" + +OneSevenLiveMultiRtmpStreamController::OneSevenLiveMultiRtmpStreamController() { + MULTI_RTMP_STREAM_LOG_INFO("Creating MultiRTMP Stream Controller"); +} + +OneSevenLiveMultiRtmpStreamController::~OneSevenLiveMultiRtmpStreamController() { + MULTI_RTMP_STREAM_LOG_INFO("Destroying MultiRTMP Stream Controller"); + + // Stop statistics monitoring + stopStatsMonitoring(); + + // Destroy all outputs + destroyAllOutputs(); + + // Release shared encoders + if (m_sharedVideoEncoder) { + obs_encoder_release(m_sharedVideoEncoder); + m_sharedVideoEncoder = nullptr; + } + + for (auto& [mixerId, encoder] : m_sharedAudioEncoders) { + if (encoder) { + obs_encoder_release(encoder); + } + } + m_sharedAudioEncoders.clear(); +} + +bool OneSevenLiveMultiRtmpStreamController::createOutput( + const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + MULTI_RTMP_STREAM_LOG_INFO("Creating output for stream: %s", streamId.c_str()); + + // Check if output already exists + if (m_streamOutputs.find(streamId) != m_streamOutputs.end()) { + MULTI_RTMP_STREAM_LOG_WARNING("Output already exists for stream: %s", streamId.c_str()); + return true; + } + + // Create stream output structure + auto streamOutput = std::make_unique(); + streamOutput->config = config; + streamOutput->status.id = streamId; + streamOutput->status.state = OneSevenLiveMultiRtmpStreamStatus::STOPPED; + streamOutput->stats.id = streamId; + + // Store early to allow async resolution to populate + m_streamOutputs[streamId] = std::move(streamOutput); + auto* storedOutput = m_streamOutputs[streamId].get(); + + // Create service + if (!createService(streamId, config, storedOutput)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create service for stream: %s", streamId.c_str()); + return false; + } + + // Create encoders + if (!createEncoders(streamId, config, storedOutput)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create encoders for stream: %s", streamId.c_str()); + return false; + } + + // Setup output + if (storedOutput->service) { + if (!setupOutput(streamId, config, storedOutput)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to setup output for stream: %s", streamId.c_str()); + return false; + } + } else { + MULTI_RTMP_STREAM_LOG_INFO( + "Service not yet available for stream: %s; awaiting async resolution", + streamId.c_str()); + } + + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::startOutput(const std::string& streamId) { + MULTI_RTMP_STREAM_LOG_INFO("=== STARTING OUTPUT FOR STREAM: %s ===", streamId.c_str()); + + auto it = m_streamOutputs.find(streamId); + if (it == m_streamOutputs.end()) { + MULTI_RTMP_STREAM_LOG_ERROR("Stream output not found: %s", streamId.c_str()); + return false; + } + + if (!it->second->output) { + MULTI_RTMP_STREAM_LOG_INFO( + "Output not ready for stream: %s; waiting for async service/key resolution", + streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::CONNECTING); + return true; + } + + return startOutputInternal(streamId, it->second.get()); +} + +bool OneSevenLiveMultiRtmpStreamController::startOutputInternal(const std::string& streamId, + StreamOutput* streamOutput) { + MULTI_RTMP_STREAM_LOG_INFO("Starting output internal for stream: %s", streamId.c_str()); + + if (!streamOutput || !streamOutput->output) { + MULTI_RTMP_STREAM_LOG_ERROR("Invalid stream output for: %s", streamId.c_str()); + return false; + } + + if (obs_output_active(streamOutput->output)) { + MULTI_RTMP_STREAM_LOG_WARNING("Output already active for stream: %s", streamId.c_str()); + return true; + } + + MULTI_RTMP_STREAM_LOG_INFO("Calling obs_output_start for stream: %s", streamId.c_str()); + + // Start the output + if (!obs_output_start(streamOutput->output)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to start output for stream: %s", streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, "StartFailed"); + return false; + } + + MULTI_RTMP_STREAM_LOG_INFO("obs_output_start succeeded for stream: %s", streamId.c_str()); + + // Update status + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::CONNECTING); + + // Setup connect timeout timer + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + streamOutput->connectTimeoutTimer = new QTimer(QCoreApplication::instance()); + streamOutput->connectTimeoutTimer->setSingleShot(true); + QObject::connect(streamOutput->connectTimeoutTimer, &QTimer::timeout, [this, streamId]() { + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + StreamOutput* so = it->second.get(); + if (so && (so->status.state == OneSevenLiveMultiRtmpStreamStatus::CONNECTING || + so->status.state == OneSevenLiveMultiRtmpStreamStatus::RECONNECTING)) { + MULTI_RTMP_STREAM_LOG_WARNING("Connect timeout for stream: %s", streamId.c_str()); + if (so->output) { + obs_output_stop(so->output); + } + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + "NetworkError:RTMP:Timeout"); + } + } + }); + QMetaObject::invokeMethod( + streamOutput->connectTimeoutTimer, + [timer = streamOutput->connectTimeoutTimer]() { timer->start(CONNECT_TIMEOUT_MS); }, + Qt::QueuedConnection); + + MULTI_RTMP_STREAM_LOG_INFO("startOutputInternal completed for stream: %s", streamId.c_str()); + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::stopOutput(const std::string& streamId) { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it == m_streamOutputs.end()) { + MULTI_RTMP_STREAM_LOG_ERROR("Stream output not found: %s", streamId.c_str()); + return false; + } + + return stopOutputInternal(streamId, it->second.get()); +} + +bool OneSevenLiveMultiRtmpStreamController::stopOutputInternal(const std::string& streamId, + StreamOutput* streamOutput) { + if (!streamOutput) { + MULTI_RTMP_STREAM_LOG_ERROR("Invalid stream output structure for: %s", streamId.c_str()); + return false; + } + + // Cancel pending connect timeout and async resolution if any + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + auto pendIt = m_pendingTwitchClients.find(streamId); + if (pendIt != m_pendingTwitchClients.end()) { + m_pendingTwitchClients.erase(pendIt); + } + + if (!streamOutput->output) { + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::STOPPED); + return true; + } + + if (!obs_output_active(streamOutput->output)) { + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::STOPPED); + return true; + } + + obs_output_stop(streamOutput->output); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::STOPPED); + + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::destroyOutput(const std::string& streamId) { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it == m_streamOutputs.end()) { + return true; + } + + auto& streamOutput = it->second; + + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + + if (streamOutput->output && obs_output_active(streamOutput->output)) { + obs_output_stop(streamOutput->output); + QElapsedTimer t; + t.start(); + while (obs_output_active(streamOutput->output) && t.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + + if (streamOutput->output) { + signal_handler_t* handler = obs_output_get_signal_handler(streamOutput->output); + if (handler) { + signal_handler_disconnect(handler, "start", outputStartCallback, this); + signal_handler_disconnect(handler, "stop", outputStopCallback, this); + signal_handler_disconnect(handler, "reconnect", outputReconnectCallback, this); + signal_handler_disconnect(handler, "reconnect_success", outputReconnectSuccessCallback, + this); + } + } + if (streamOutput->output) { + obs_output_release(streamOutput->output); + } + if (streamOutput->service) { + obs_service_release(streamOutput->service); + } + if (streamOutput->videoEncoder && streamOutput->config.videoConfig.has_value()) { + obs_encoder_release(streamOutput->videoEncoder); + } + if (streamOutput->audioEncoder && streamOutput->config.audioConfig.has_value()) { + obs_encoder_release(streamOutput->audioEncoder); + } + + m_streamOutputs.erase(it); + + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::startAllOutputs() { + MULTI_RTMP_STREAM_LOG_INFO("Starting all outputs"); + + bool allStarted = true; + for (const auto& [streamId, streamOutput] : m_streamOutputs) { + if (!obs_output_active(streamOutput->output)) { + if (!startOutputInternal(streamId, streamOutput.get())) { + allStarted = false; + MULTI_RTMP_STREAM_LOG_ERROR("Failed to start output for stream: %s", + streamId.c_str()); + } + } + } + + return allStarted; +} + +bool OneSevenLiveMultiRtmpStreamController::stopAllOutputs() { + bool allStopped = true; + for (const auto& [streamId, streamOutput] : m_streamOutputs) { + // Stop if stream is technically active (Connecting, Streaming, Reconnecting) + // OR if the OBS output is active (fallback check) + bool shouldStop = + streamOutput->status.state != OneSevenLiveMultiRtmpStreamStatus::STOPPED && + streamOutput->status.state != OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE; + + if (streamOutput->output && obs_output_active(streamOutput->output)) { + shouldStop = true; + } + + if (shouldStop) { + if (!stopOutputInternal(streamId, streamOutput.get())) { + allStopped = false; + MULTI_RTMP_STREAM_LOG_ERROR("Failed to stop output for stream: %s", + streamId.c_str()); + } + } + } + return allStopped; +} + +void OneSevenLiveMultiRtmpStreamController::destroyAllOutputs() { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + for (auto& [streamId, streamOutput] : m_streamOutputs) { + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + if (streamOutput->output && obs_output_active(streamOutput->output)) { + obs_output_stop(streamOutput->output); + QElapsedTimer t; + t.start(); + while (obs_output_active(streamOutput->output) && t.elapsed() < 5000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + } + if (streamOutput->output) { + signal_handler_t* handler = obs_output_get_signal_handler(streamOutput->output); + if (handler) { + signal_handler_disconnect(handler, "start", outputStartCallback, this); + signal_handler_disconnect(handler, "stop", outputStopCallback, this); + signal_handler_disconnect(handler, "reconnect", outputReconnectCallback, this); + signal_handler_disconnect(handler, "reconnect_success", + outputReconnectSuccessCallback, this); + } + } + if (streamOutput->output) { + obs_output_release(streamOutput->output); + } + if (streamOutput->service) { + obs_service_release(streamOutput->service); + } + if (streamOutput->videoEncoder && streamOutput->config.videoConfig.has_value()) { + obs_encoder_release(streamOutput->videoEncoder); + } + if (streamOutput->audioEncoder && streamOutput->config.audioConfig.has_value()) { + obs_encoder_release(streamOutput->audioEncoder); + } + } + + m_streamOutputs.clear(); +} + +OneSevenLiveMultiRtmpStreamStatus OneSevenLiveMultiRtmpStreamController::getStreamStatus( + const std::string& streamId) const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + return it->second->status; + } + + OneSevenLiveMultiRtmpStreamStatus status; + status.id = streamId; + status.state = OneSevenLiveMultiRtmpStreamStatus::STOPPED; + return status; +} + +OneSevenLiveMultiRtmpStreamStats OneSevenLiveMultiRtmpStreamController::getStreamStats( + const std::string& streamId) const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + return it->second->stats; + } + + OneSevenLiveMultiRtmpStreamStats stats; + stats.id = streamId; + return stats; +} + +std::vector OneSevenLiveMultiRtmpStreamController::getActiveStreamIds() const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + std::vector activeIds; + for (const auto& [streamId, streamOutput] : m_streamOutputs) { + if (streamOutput->output && obs_output_active(streamOutput->output)) { + activeIds.push_back(streamId); + } + } + + return activeIds; +} + +std::vector OneSevenLiveMultiRtmpStreamController::getAllStreamIds() const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + std::vector allIds; + allIds.reserve(m_streamOutputs.size()); + + for (const auto& [streamId, streamOutput] : m_streamOutputs) { + allIds.push_back(streamId); + } + + return allIds; +} + +obs_encoder_t* OneSevenLiveMultiRtmpStreamController::getSharedVideoEncoder() { + MULTI_RTMP_STREAM_LOG_INFO( + "Shared video encoder disabled; using dedicated encoders per output"); + return nullptr; +} + +obs_encoder_t* OneSevenLiveMultiRtmpStreamController::getSharedAudioEncoder(int mixerId) { + (void) mixerId; + MULTI_RTMP_STREAM_LOG_INFO( + "Shared audio encoder disabled; using dedicated encoders per output"); + return nullptr; +} + +bool OneSevenLiveMultiRtmpStreamController::isStreamActive(const std::string& streamId) const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + return it->second->output && obs_output_active(it->second->output); + } + + return false; +} + +bool OneSevenLiveMultiRtmpStreamController::hasOutput(const std::string& streamId) const { + // TEMPORARILY REMOVED LOCK FOR DEBUGGING - DEADLOCK PREVENTION + // std::lock_guard lock(m_outputsMutex); + return m_streamOutputs.find(streamId) != m_streamOutputs.end(); +} + +obs_output_t* OneSevenLiveMultiRtmpStreamController::getStreamOutput( + const std::string& streamId) const { + std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + return it->second->output; + } + + return nullptr; +} + +void OneSevenLiveMultiRtmpStreamController::setStreamStatusCallback(StreamStatusCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_statusCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpStreamController::setStreamStatsCallback(StreamStatsCallback callback) { + std::lock_guard lock(m_callbackMutex); + m_statsCallback = std::move(callback); +} + +void OneSevenLiveMultiRtmpStreamController::startStatsMonitoring() { + std::lock_guard lock(m_statsThreadMutex); + + if (m_statsMonitoringActive) { + return; + } + + m_statsMonitoringActive = true; + m_statsThread = + std::thread(&OneSevenLiveMultiRtmpStreamController::statsMonitoringThread, this); + + MULTI_RTMP_STREAM_LOG_INFO("Statistics monitoring started"); +} + +void OneSevenLiveMultiRtmpStreamController::stopStatsMonitoring() { + { + std::lock_guard lock(m_statsThreadMutex); + m_statsMonitoringActive = false; + } + + if (m_statsThread.joinable()) { + m_statsThread.join(); + } + + MULTI_RTMP_STREAM_LOG_INFO("Statistics monitoring stopped"); +} + +bool OneSevenLiveMultiRtmpStreamController::createService(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput) { + if (!streamOutput) { + MULTI_RTMP_STREAM_LOG_ERROR("StreamOutput is null for stream: %s", streamId.c_str()); + return false; + } + ObsDataPtr serviceSettings{createServiceSettings(config)}; + if (!serviceSettings) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create base service settings for stream: %s", + streamId.c_str()); + return false; + } + + const char* server = obs_data_get_string(serviceSettings.get(), "server"); + const char* key = obs_data_get_string(serviceSettings.get(), "key"); + const bool hasServer = server && *server; + const bool hasKey = key && *key; + + if (hasServer && hasKey) { + streamOutput->service = obs_service_create(SERVICE_ID, getServiceName(streamId).c_str(), + serviceSettings.get(), nullptr); + serviceSettings.reset(); + if (!streamOutput->service) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create service for stream: %s", + streamId.c_str()); + return false; + } + return true; + } + + serviceSettings.reset(); + MULTI_RTMP_STREAM_LOG_INFO("Server/key missing for stream: %s; resolving asynchronously", + streamId.c_str()); + resolvePlatformServerKeyAsync(streamId, config); + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::createEncoders( + const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput) { + if (!streamOutput) { + MULTI_RTMP_STREAM_LOG_ERROR("StreamOutput is null for stream: %s", streamId.c_str()); + return false; + } + + // Video encoder: always create dedicated encoder + { + ObsDataPtr videoSettings{createVideoEncoderSettings(config)}; + if (!videoSettings) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create video encoder settings for stream: %s", + streamId.c_str()); + return false; + } + const char* videoEncoderId = + config.videoConfig.has_value() && !config.videoConfig->encoderId.empty() + ? config.videoConfig->encoderId.c_str() + : getObsDefaultVideoEncoderId(); + streamOutput->videoEncoder = obs_video_encoder_create( + videoEncoderId, getVideoEncoderName(streamId).c_str(), videoSettings.get(), nullptr); + videoSettings.reset(); + if (!streamOutput->videoEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create video encoder for stream: %s", + streamId.c_str()); + return false; + } + } + + // Audio encoder: always create dedicated encoder + { + ObsDataPtr audioSettings{createAudioEncoderSettings(config)}; + if (!audioSettings) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create audio encoder settings for stream: %s", + streamId.c_str()); + return false; + } + const char* audioEncoderId = + (config.audioConfig.has_value() && !config.audioConfig->encoderId.empty()) + ? config.audioConfig->encoderId.c_str() + : AUDIO_ENCODER_ID; + streamOutput->audioEncoder = obs_audio_encoder_create( + audioEncoderId, getAudioEncoderName(streamId).c_str(), audioSettings.get(), 0, nullptr); + audioSettings.reset(); + if (!streamOutput->audioEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create audio encoder for stream: %s", + streamId.c_str()); + return false; + } + } + + // Final validation to ensure both encoders are available + if (!streamOutput->videoEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Video encoder is null after creation for stream: %s", + streamId.c_str()); + return false; + } + + if (!streamOutput->audioEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Audio encoder is null after creation for stream: %s", + streamId.c_str()); + return false; + } + + // Connect encoders to video and audio sources + obs_encoder_set_video(streamOutput->videoEncoder, obs_get_video()); + obs_encoder_set_audio(streamOutput->audioEncoder, obs_get_audio()); + + return true; +} + +bool OneSevenLiveMultiRtmpStreamController::setupOutput(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput) { + if (!streamOutput) { + MULTI_RTMP_STREAM_LOG_ERROR("StreamOutput is null for stream: %s", streamId.c_str()); + return false; + } + + // Validate that all required components are available + if (!streamOutput->service) { + MULTI_RTMP_STREAM_LOG_ERROR("Service is null for stream: %s", streamId.c_str()); + return false; + } + + if (!streamOutput->videoEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Video encoder is null for stream: %s", streamId.c_str()); + return false; + } + + if (!streamOutput->audioEncoder) { + MULTI_RTMP_STREAM_LOG_ERROR("Audio encoder is null for stream: %s", streamId.c_str()); + return false; + } + + ObsDataPtr outputSettings{createOutputSettings(config)}; + if (!outputSettings) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create output settings for stream: %s", + streamId.c_str()); + return false; + } + + streamOutput->output = obs_output_create(OUTPUT_ID, getOutputName(streamId).c_str(), + outputSettings.get(), nullptr); + outputSettings.reset(); + + if (!streamOutput->output) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create output for stream: %s", streamId.c_str()); + return false; + } + + // Set service and encoders + obs_output_set_service(streamOutput->output, streamOutput->service); + obs_output_set_video_encoder(streamOutput->output, streamOutput->videoEncoder); + obs_output_set_audio_encoder(streamOutput->output, streamOutput->audioEncoder, 0); + + // Set callbacks + signal_handler_t* handler = obs_output_get_signal_handler(streamOutput->output); + signal_handler_connect(handler, "start", outputStartCallback, this); + signal_handler_connect(handler, "stop", outputStopCallback, this); + signal_handler_connect(handler, "reconnect", outputReconnectCallback, this); + signal_handler_connect(handler, "reconnect_success", outputReconnectSuccessCallback, this); + + MULTI_RTMP_STREAM_LOG_INFO("Output setup completed successfully for stream: %s", + streamId.c_str()); + return true; +} + +void OneSevenLiveMultiRtmpStreamController::updateStreamStatus( + const std::string& streamId, OneSevenLiveMultiRtmpStreamStatus::State state, + const std::string& error) { + // NOTE: NOT ADDING MUTEX LOCK HERE FOR DEBUGGING - POTENTIAL DEADLOCK SOURCE + // This method is called from OBS callbacks which may already hold locks + // std::lock_guard lock(m_outputsMutex); + + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + it->second->status.state = state; + it->second->status.errorMessage = error; + StreamStatusCallback cb; + OneSevenLiveMultiRtmpStreamStatus statusCopy = it->second->status; + { + std::lock_guard lock(m_callbackMutex); + cb = m_statusCallback; + } + if (cb) { + cb(streamId, statusCopy); + } + } +} + +// Static callback implementations +void OneSevenLiveMultiRtmpStreamController::outputStartCallback(void* data, calldata_t* cd) { + auto* controller = static_cast(data); + obs_output_t* output = static_cast(calldata_ptr(cd, "output")); + + MULTI_RTMP_STREAM_LOG_INFO("=== OUTPUT START CALLBACK TRIGGERED ==="); + + // Find stream ID by output - TEMPORARILY REMOVED LOCK FOR DEBUGGING + // std::lock_guard lock(controller->m_outputsMutex); + for (const auto& [streamId, streamOutput] : controller->m_streamOutputs) { + if (streamOutput->output == output) { + MULTI_RTMP_STREAM_LOG_INFO("Found matching stream in callback: %s", streamId.c_str()); + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + controller->updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::STREAMING); + { + std::string platform = streamOutput->config.streamName; + std::transform(platform.begin(), platform.end(), platform.begin(), ::tolower); + if (platform.find("twitch") != std::string::npos) { + auto& core = OneSevenLiveCoreManager::getInstance(); + QMetaObject::invokeMethod( + &core, [&core]() { core.connectTwitchChatClient(QString()); }, + Qt::QueuedConnection); + } + if (platform.find("youtube") != std::string::npos) { + auto& core = OneSevenLiveCoreManager::getInstance(); + QMetaObject::invokeMethod( + &core, + [&core]() { + auto* ytAuth = core.getYouTubeAuth(); + if (ytAuth && ytAuth->hasValidToken()) { + QString caption; + auto* sm = core.getStreamManager(); + if (sm) { + caption = sm->getCurrentStreamRequest().caption; + } + core.orchestrateYouTubeBroadcast(caption.isEmpty() ? QString("Live") + : caption); + } + }, + Qt::QueuedConnection); + } + } + MULTI_RTMP_STREAM_LOG_INFO("Stream started: %s", streamId.c_str()); + break; + } + } +} + +void OneSevenLiveMultiRtmpStreamController::outputStopCallback(void* data, calldata_t* cd) { + auto* controller = static_cast(data); + obs_output_t* output = static_cast(calldata_ptr(cd, "output")); + + std::lock_guard lock(controller->m_outputsMutex); + for (const auto& [streamId, streamOutput] : controller->m_streamOutputs) { + if (streamOutput->output == output) { + if (streamOutput->status.state == OneSevenLiveMultiRtmpStreamStatus::CONNECTING || + streamOutput->status.state == OneSevenLiveMultiRtmpStreamStatus::RECONNECTING) { + std::string detail; + const char* lastErr = nullptr; + const char* reason = nullptr; + int code = 0; + // Attempt to read error fields from calldata if present + lastErr = calldata_string(cd, "last_error"); + if (!lastErr) + lastErr = calldata_string(cd, "error"); + reason = calldata_string(cd, "reason"); + code = calldata_int(cd, "code"); + if (lastErr && *lastErr) { + detail = lastErr; + } else if (reason && *reason) { + detail = reason; + } else if (code != 0) { + detail = std::string("code=") + std::to_string(code); + } + std::string errMsg; + std::string dlow = detail; + std::transform(dlow.begin(), dlow.end(), dlow.begin(), ::tolower); + bool isNet = dlow.find("tls") != std::string::npos || + dlow.find("ssl") != std::string::npos || + dlow.find("timeout") != std::string::npos || + dlow.find("connection") != std::string::npos || + dlow.find("recv") != std::string::npos || + dlow.find("reset") != std::string::npos || + dlow.find("handshake") != std::string::npos || + dlow.find("network") != std::string::npos || + dlow.find("code=-2") != std::string::npos; + errMsg = isNet ? "NetworkError:RTMP" : "ConnectFailed"; + if (!detail.empty()) + errMsg += ":" + detail; + controller->updateStreamStatus( + streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, errMsg); + } else { + controller->updateStreamStatus(streamId, + OneSevenLiveMultiRtmpStreamStatus::STOPPED); + } + { + std::string platform = streamOutput->config.streamName; + std::transform(platform.begin(), platform.end(), platform.begin(), ::tolower); + if (platform.find("twitch") != std::string::npos) { + auto& core = OneSevenLiveCoreManager::getInstance(); + QMetaObject::invokeMethod( + &core, [&core]() { core.disconnectTwitchChatClient(); }, + Qt::QueuedConnection); + } + if (platform.find("youtube") != std::string::npos) { + auto& core = OneSevenLiveCoreManager::getInstance(); + QMetaObject::invokeMethod( + &core, [&core]() { core.stopYouTubeChatPolling(); }, Qt::QueuedConnection); + } + } + MULTI_RTMP_STREAM_LOG_INFO("Stream stopped: %s", streamId.c_str()); + break; + } + } +} + +void OneSevenLiveMultiRtmpStreamController::outputReconnectCallback(void* data, calldata_t* cd) { + auto* controller = static_cast(data); + obs_output_t* output = static_cast(calldata_ptr(cd, "output")); + + std::lock_guard lock(controller->m_outputsMutex); + for (const auto& [streamId, streamOutput] : controller->m_streamOutputs) { + if (streamOutput->output == output) { + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + controller->updateStreamStatus(streamId, + OneSevenLiveMultiRtmpStreamStatus::RECONNECTING); + MULTI_RTMP_STREAM_LOG_INFO("Stream reconnecting: %s", streamId.c_str()); + break; + } + } +} + +void OneSevenLiveMultiRtmpStreamController::outputReconnectSuccessCallback(void* data, + calldata_t* cd) { + auto* controller = static_cast(data); + obs_output_t* output = static_cast(calldata_ptr(cd, "output")); + + std::lock_guard lock(controller->m_outputsMutex); + for (const auto& [streamId, streamOutput] : controller->m_streamOutputs) { + if (streamOutput->output == output) { + if (streamOutput->connectTimeoutTimer) { + QTimer* t = streamOutput->connectTimeoutTimer; + QMetaObject::invokeMethod( + t, + [t]() { + t->stop(); + t->deleteLater(); + }, + Qt::QueuedConnection); + streamOutput->connectTimeoutTimer = nullptr; + } + controller->updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::STREAMING); + MULTI_RTMP_STREAM_LOG_INFO("Stream reconnected successfully: %s", streamId.c_str()); + break; + } + } +} + +void OneSevenLiveMultiRtmpStreamController::statsMonitoringThread() { + while (m_statsMonitoringActive) { + { + std::lock_guard lock(m_outputsMutex); + for (auto& [streamId, streamOutput] : m_streamOutputs) { + if (streamOutput->output && obs_output_active(streamOutput->output)) { + collectStreamStats(streamId, *streamOutput); + + if (m_statsCallback) { + m_statsCallback(streamId, streamOutput->stats); + } + } + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(STATS_UPDATE_INTERVAL_MS)); + } +} + +void OneSevenLiveMultiRtmpStreamController::collectStreamStats(const std::string& streamId, + StreamOutput& streamOutput) { + (void) streamId; // Suppress unused parameter warning + + if (!streamOutput.output) { + return; + } + + // Update duration + auto now = std::chrono::steady_clock::now(); + streamOutput.stats.duration = now - streamOutput.startTime; + + // Get output statistics + streamOutput.stats.totalFrames = + static_cast(obs_output_get_total_frames(streamOutput.output)); + streamOutput.stats.droppedFrames = + static_cast(obs_output_get_frames_dropped(streamOutput.output)); + + // Calculate bitrate and FPS (these would need to be implemented based on OBS API) + // For now, we'll use placeholder values + streamOutput.stats.currentBitrate = 0.0; // Would need actual implementation + streamOutput.stats.currentFPS = 0; // Would need actual implementation + streamOutput.stats.cpuUsage = 0.0; // Would need actual implementation + + if (streamOutput.stats.totalFrames > 0) { + streamOutput.stats.averageBitrate = streamOutput.stats.currentBitrate; // Simplified + } +} + +// Helper method implementations +std::string OneSevenLiveMultiRtmpStreamController::getOutputName( + const std::string& streamId) const { + return "MultiRTMP_Output_" + streamId; +} + +std::string OneSevenLiveMultiRtmpStreamController::getServiceName( + const std::string& streamId) const { + return "MultiRTMP_Service_" + streamId; +} + +std::string OneSevenLiveMultiRtmpStreamController::getVideoEncoderName( + const std::string& streamId) const { + return "MultiRTMP_VideoEncoder_" + streamId; +} + +std::string OneSevenLiveMultiRtmpStreamController::getAudioEncoderName( + const std::string& streamId) const { + return "MultiRTMP_AudioEncoder_" + streamId; +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::createServiceSettings( + const OneSevenLiveMultiRtmpConfig& config) const { + obs_log(LOG_INFO, "createServiceSettings (non-blocking)"); + obs_data_t* settings = ObsDataFromJson(config.serviceSettings); + if (!settings) { + settings = obs_data_create(); + } + + const std::string platform = config.streamName; + if (platform == "YouTube") { + obs_data_set_string(settings, "service", "YouTube - RTMPS"); + } else if (platform == "Twitch") { + obs_data_set_string(settings, "service", "Twitch"); + } + + // Return settings as-is; async resolution will fill server/key if missing + return settings; +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::createOutputSettings( + const OneSevenLiveMultiRtmpConfig& config) const { + obs_log(LOG_INFO, "createOutputSettings"); + obs_data_t* settings = ObsDataFromJson(config.outputSettings); + // TODO: Add any additional output settings here + return settings; +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::createVideoEncoderSettings( + const OneSevenLiveMultiRtmpConfig& config) const { + obs_log(LOG_INFO, "createVideoEncoderSettings"); + + // Use custom settings when videoConfig is provided; otherwise use OBS defaults + if (config.videoConfig.has_value()) { + const nlohmann::json& j = config.videoConfig->encoderSettings; + if (!j.is_null()) { + obs_data_t* settings = ObsDataFromJson(j); + if (settings) { + MULTI_RTMP_STREAM_LOG_DEBUG("Using custom video encoder settings from JSON"); + return settings; + } + } + MULTI_RTMP_STREAM_LOG_DEBUG( + "Video encoderSettings empty or invalid JSON, using empty settings"); + return obs_data_create(); + } + + MULTI_RTMP_STREAM_LOG_DEBUG( + "No custom video config provided, using OBS default video encoder settings"); + return getObsDefaultVideoEncoderSettings(); +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::createAudioEncoderSettings( + const OneSevenLiveMultiRtmpConfig& config) const { + obs_log(LOG_INFO, "createAudioEncoderSettings"); + // Use custom settings when audioConfig is provided; otherwise use OBS defaults + if (config.audioConfig.has_value()) { + const nlohmann::json& j = config.audioConfig->encoderSettings; + if (!j.is_null()) { + obs_data_t* settings = ObsDataFromJson(j); + if (settings) { + MULTI_RTMP_STREAM_LOG_DEBUG("Using custom audio encoder settings from JSON"); + return settings; + } + } + MULTI_RTMP_STREAM_LOG_DEBUG( + "Audio encoderSettings empty or invalid JSON, using empty settings"); + return obs_data_create(); + } + + MULTI_RTMP_STREAM_LOG_DEBUG( + "No custom audio config provided, using OBS default audio encoder settings"); + return getObsDefaultAudioEncoderSettings(); +} + +void OneSevenLiveMultiRtmpStreamController::resolvePlatformServerKeyAsync( + const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + QTimer::singleShot(0, [this, streamId, config]() { + std::string platform; + try { + if (config.serviceSettings.contains("service") && + config.serviceSettings["service"].is_string()) { + platform = config.serviceSettings["service"].get(); + } + } catch (...) { + } + if (platform.empty()) { + platform = config.streamName; + } + + auto contains_ci = [](const std::string& s, const std::string& needle) { + std::string hs = s, hn = needle; + std::transform(hs.begin(), hs.end(), hs.begin(), ::tolower); + std::transform(hn.begin(), hn.end(), hn.begin(), ::tolower); + return hs.find(hn) != std::string::npos; + }; + + if (contains_ci(platform, "youtube")) { + MULTI_RTMP_STREAM_LOG_INFO( + "YouTube platform disabled; skipping server/key resolution for %s", + streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + "Disabled:YouTube"); + return; + } else if (contains_ci(platform, "twitch")) { + auto* twAuth = OneSevenLiveCoreManager::getInstance().getTwitchAuth(); + OneSevenLiveTwitchClient* client = nullptr; + if (twAuth) { + client = twAuth->getTwitchClient(); + } + if (!client) { + m_pendingTwitchClients[streamId] = std::make_unique(); + client = m_pendingTwitchClients[streamId].get(); + } + + if (!twAuth || !twAuth->hasValidToken()) { + MULTI_RTMP_STREAM_LOG_WARNING("Twitch auth not available; cannot resolve for %s", + streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + "AuthInvalid:Twitch"); + return; + } + + if (!client->hasValidAuth()) { + client->setAuthData(twAuth->getAccessToken(), QString(TWITCH_API_CLIENT_ID)); + } + + QPointer timeout = new QTimer(client); + timeout->setSingleShot(true); + QObject::connect(timeout, &QTimer::timeout, [this, streamId, timeout]() { + MULTI_RTMP_STREAM_LOG_WARNING("Twitch resolve timeout for stream: %s", + streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + "NetworkError:Twitch:Timeout"); + m_pendingTwitchClients.erase(streamId); + if (timeout) + timeout->deleteLater(); + }); + + auto userConnPtr = std::make_shared(); + *userConnPtr = QObject::connect(client, &OneSevenLiveTwitchClient::userInfoReceived, + [client, userConnPtr](const TwitchUserInfo& user) { + if (client) + client->getStreamKey(user.id); + QObject::disconnect(*userConnPtr); + }); + + auto keyConnPtr = std::make_shared(); + *keyConnPtr = QObject::connect( + client, &OneSevenLiveTwitchClient::streamKeyReceived, + [this, streamId, timeout, keyConnPtr](const QString& keyVal) { + if (timeout) + timeout->stop(); + std::string serverUrl = getRecommendedTwitchServer(); + if (serverUrl.empty()) { + serverUrl = + OneSevenLiveTwitchClient::TWITCH_RTMP_SERVER.toUtf8().constData(); + } + finalizeServiceSetupAfterResolve(streamId, serverUrl.c_str(), + keyVal.toUtf8().constData()); + QObject::disconnect(*keyConnPtr); + }); + + QObject::connect( + client, &OneSevenLiveTwitchClient::errorOccurred, + [this, streamId, timeout](const QString& err) { + if (timeout) + timeout->stop(); + MULTI_RTMP_STREAM_LOG_WARNING("Twitch resolve error for stream: %s", + streamId.c_str()); + QString e = err.toLower(); + bool isNet = e.contains("recv failure") || e.contains("connection reset") || + e.contains("timeout") || e.contains("could not resolve") || + e.contains("dns") || e.contains("tls") || e.contains("ssl") || + e.contains("handshake") || e.contains("network"); + std::string msg = + std::string(isNet ? "NetworkError:Twitch:" : "APIError:Twitch:") + + err.toUtf8().constData(); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + msg.c_str()); + }); + + timeout->start(5000); + client->getCurrentUser(); + } else { + MULTI_RTMP_STREAM_LOG_WARNING("Unknown platform for stream: %s", streamId.c_str()); + updateStreamStatus(streamId, OneSevenLiveMultiRtmpStreamStatus::ERROR_STATE, + "UnknownPlatform"); + } + }); +} + +void OneSevenLiveMultiRtmpStreamController::finalizeServiceSetupAfterResolve( + const std::string& streamId, const std::string& server, const std::string& key) { + auto it = m_streamOutputs.find(streamId); + if (it == m_streamOutputs.end()) { + MULTI_RTMP_STREAM_LOG_ERROR("StreamOutput missing during finalize for: %s", + streamId.c_str()); + return; + } + + auto* streamOutput = it->second.get(); + // If user requested stop while resolving, do not finalize + if (streamOutput->status.state == OneSevenLiveMultiRtmpStreamStatus::STOPPED) { + MULTI_RTMP_STREAM_LOG_INFO("Finalize skipped; stream stopped: %s", streamId.c_str()); + return; + } + const std::string platform = streamOutput->config.streamName; + + ObsDataPtr settings{ObsDataFromJson(streamOutput->config.serviceSettings)}; + if (!settings) + settings.reset(obs_data_create()); + if (platform == "YouTube") { + obs_data_set_string(settings.get(), "service", "YouTube - RTMPS"); + } else if (platform == "Twitch") { + obs_data_set_string(settings.get(), "service", "Twitch"); + } + obs_data_set_string(settings.get(), "server", server.c_str()); + obs_data_set_string(settings.get(), "key", key.c_str()); + + streamOutput->service = + obs_service_create(SERVICE_ID, getServiceName(streamId).c_str(), settings.get(), nullptr); + settings.reset(); + if (!streamOutput->service) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create service after resolve for: %s", + streamId.c_str()); + return; + } + + if (!streamOutput->videoEncoder || !streamOutput->audioEncoder) { + if (!createEncoders(streamId, streamOutput->config, streamOutput)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to create encoders after resolve for: %s", + streamId.c_str()); + return; + } + } + + if (!streamOutput->output) { + if (!setupOutput(streamId, streamOutput->config, streamOutput)) { + MULTI_RTMP_STREAM_LOG_ERROR("Failed to setup output after resolve for: %s", + streamId.c_str()); + return; + } + } + + // Cleanup pending clients if any + m_pendingTwitchClients.erase(streamId); + + (void) startOutputInternal(streamId, streamOutput); +} + +void OneSevenLiveMultiRtmpStreamController::destroyService(const std::string& streamId) { + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end() && it->second->service) { + obs_service_release(it->second->service); + it->second->service = nullptr; + MULTI_RTMP_STREAM_LOG_DEBUG("Service destroyed for stream: %s", streamId.c_str()); + } +} + +void OneSevenLiveMultiRtmpStreamController::destroyEncoders(const std::string& streamId) { + auto it = m_streamOutputs.find(streamId); + if (it != m_streamOutputs.end()) { + if (it->second->videoEncoder) { + obs_encoder_release(it->second->videoEncoder); + it->second->videoEncoder = nullptr; + } + if (it->second->audioEncoder) { + obs_encoder_release(it->second->audioEncoder); + it->second->audioEncoder = nullptr; + } + MULTI_RTMP_STREAM_LOG_DEBUG("Encoders destroyed for stream: %s", streamId.c_str()); + } +} + +void OneSevenLiveMultiRtmpStreamController::updateStreamStats(const std::string& streamId) { + OneSevenLiveMultiRtmpStreamStats statsCopy; + { + std::lock_guard lock(m_outputsMutex); + auto it = m_streamOutputs.find(streamId); + if (it == m_streamOutputs.end()) { + return; + } + collectStreamStats(streamId, *it->second); + statsCopy = it->second->stats; + } + StreamStatsCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_statsCallback; + } + if (cb) { + cb(streamId, statsCopy); + } +} + +const char* OneSevenLiveMultiRtmpStreamController::getObsDefaultVideoEncoderId() const { + // Try to get the encoder ID from the main streaming output + obs_output_t* streamingOutput = obs_frontend_get_streaming_output(); + if (streamingOutput) { + obs_encoder_t* videoEncoder = obs_output_get_video_encoder(streamingOutput); + if (videoEncoder) { + const char* encoderId = obs_encoder_get_id(videoEncoder); + obs_output_release(streamingOutput); + return encoderId ? encoderId : VIDEO_ENCODER_ID; + } + obs_output_release(streamingOutput); + } + + // Fallback to default x264 encoder + return VIDEO_ENCODER_ID; +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::getObsDefaultVideoEncoderSettings() const { + obs_data_t* settings = obs_data_create(); + + // Try to get settings from the main streaming output + obs_output_t* streamingOutput = obs_frontend_get_streaming_output(); + if (streamingOutput) { + obs_encoder_t* videoEncoder = obs_output_get_video_encoder(streamingOutput); + if (videoEncoder) { + ObsDataPtr encoderSettings{obs_encoder_get_settings(videoEncoder)}; + if (encoderSettings) { + // Copy the settings + obs_data_apply(settings, encoderSettings.get()); + obs_output_release(streamingOutput); + MULTI_RTMP_STREAM_LOG_DEBUG("Using OBS default video encoder settings"); + return settings; + } + } + obs_output_release(streamingOutput); + } + + // Fallback to reasonable defaults if OBS settings are not available + obs_data_set_int(settings, "bitrate", 2500); + obs_data_set_int(settings, "keyint_sec", 2); + obs_data_set_string(settings, "rate_control", "CBR"); + obs_data_set_string(settings, "profile", "main"); + obs_data_set_bool(settings, "use_bufsize", true); + obs_data_set_bool(settings, "bframes", false); + + MULTI_RTMP_STREAM_LOG_DEBUG("Using fallback video encoder settings"); + return settings; +} + +obs_data_t* OneSevenLiveMultiRtmpStreamController::getObsDefaultAudioEncoderSettings() const { + obs_data_t* settings = obs_data_create(); + + // Try to get settings from the main streaming output + obs_output_t* streamingOutput = obs_frontend_get_streaming_output(); + if (streamingOutput) { + obs_encoder_t* audioEncoder = obs_output_get_audio_encoder(streamingOutput, 0); + if (audioEncoder) { + ObsDataPtr encoderSettings{obs_encoder_get_settings(audioEncoder)}; + if (encoderSettings) { + // Copy the settings + obs_data_apply(settings, encoderSettings.get()); + obs_output_release(streamingOutput); + MULTI_RTMP_STREAM_LOG_DEBUG("Using OBS default audio encoder settings"); + return settings; + } + } + obs_output_release(streamingOutput); + } + + // Fallback to reasonable defaults if OBS settings are not available + obs_data_set_int(settings, "bitrate", 128); + obs_data_set_int(settings, "rate", 44100); + + MULTI_RTMP_STREAM_LOG_DEBUG("Using fallback audio encoder settings"); + return settings; +} + +#include "utility/Common.hpp" + +std::string OneSevenLiveMultiRtmpStreamController::getRecommendedTwitchServer() const { + std::string best; + ObsDataPtr settings{obs_data_create()}; + obs_data_set_string(settings.get(), "service", "Twitch"); + obs_service_t* svc = + obs_service_create(SERVICE_ID, "temp_twitch_service", settings.get(), nullptr); + settings.reset(); + if (!svc) + return best; + obs_properties_t* props = obs_service_properties(svc); + if (props) { + obs_property_t* srvProp = obs_properties_get(props, "server"); + if (srvProp && obs_property_get_type(srvProp) == OBS_PROPERTY_LIST) { + size_t count = obs_property_list_item_count(srvProp); + for (size_t i = 0; i < count; ++i) { + const char* name = obs_property_list_item_name(srvProp, i); + const char* val = obs_property_list_item_string(srvProp, i); + if (!best.empty()) + continue; + if (name && (strstr(name, "Auto") || strstr(name, "Recommended"))) { + best = val ? val : ""; + } + } + if (best.empty() && count > 0) { + const char* val0 = obs_property_list_item_string(srvProp, 0); + best = val0 ? val0 : ""; + } + } + } + obs_service_release(svc); + return best; +} + +// removed global stop aggregation; rely on manager to orchestrate destroy after stop +void OneSevenLiveMultiRtmpStreamController::beginShutdown() { + m_shuttingDown.store(true); +} diff --git a/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.hpp b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.hpp new file mode 100644 index 0000000..f852a2d --- /dev/null +++ b/src/17live/multi-rtmp/OneSevenLiveMultiRtmpStreamController.hpp @@ -0,0 +1,187 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveMultiRtmpModels.hpp" +#include "plugin-support.h" + +// Forward declarations for async platform resolution +class OneSevenLiveYouTubeClient; +class OneSevenLiveTwitchClient; + +/** + * Stream Controller for Multi-RTMP functionality + * Handles ONLY OBS runtime operations (service/output creation and management) + * Does NOT handle configuration storage or JSON operations + */ +class OneSevenLiveMultiRtmpStreamController { + public: + // Callback types for stream events + using StreamStatusCallback = std::function; + using StreamStatsCallback = std::function; + + OneSevenLiveMultiRtmpStreamController(); + ~OneSevenLiveMultiRtmpStreamController(); + + // OBS output lifecycle management + bool createOutput(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config); + bool startOutput(const std::string& streamId); + bool stopOutput(const std::string& streamId); + bool destroyOutput(const std::string& streamId); + + // Bulk operations + bool startAllOutputs(); + bool stopAllOutputs(); + void destroyAllOutputs(); + void beginShutdown(); + + // Status and statistics + OneSevenLiveMultiRtmpStreamStatus getStreamStatus(const std::string& streamId) const; + OneSevenLiveMultiRtmpStreamStats getStreamStats(const std::string& streamId) const; + std::vector getActiveStreamIds() const; + std::vector getAllStreamIds() const; + + // OBS encoder sharing + obs_encoder_t* getSharedVideoEncoder(); + obs_encoder_t* getSharedAudioEncoder(int mixerId = 1); + + // Stream management + bool isStreamActive(const std::string& streamId) const; + bool hasOutput(const std::string& streamId) const; + + // OBS output access + obs_output_t* getStreamOutput(const std::string& streamId) const; + + // Callback registration + void setStreamStatusCallback(StreamStatusCallback callback); + void setStreamStatsCallback(StreamStatsCallback callback); + + // Statistics monitoring + void startStatsMonitoring(); + void stopStatsMonitoring(); + + private: + // Internal structures + struct StreamOutput { + obs_output_t* output = nullptr; + obs_service_t* service = nullptr; + obs_encoder_t* videoEncoder = nullptr; + obs_encoder_t* audioEncoder = nullptr; + OneSevenLiveMultiRtmpConfig config; + OneSevenLiveMultiRtmpStreamStatus status; + OneSevenLiveMultiRtmpStreamStats stats; + std::chrono::steady_clock::time_point startTime; + QTimer* connectTimeoutTimer = nullptr; + }; + + // Internal implementation methods + bool createService(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput); + bool createEncoders(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput); + bool setupOutput(const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config, + StreamOutput* streamOutput); + bool startOutputInternal(const std::string& streamId, StreamOutput* streamOutput); + bool stopOutputInternal(const std::string& streamId, StreamOutput* streamOutput); + + void destroyService(const std::string& streamId); + void destroyEncoders(const std::string& streamId); + + void updateStreamStatus(const std::string& streamId, + OneSevenLiveMultiRtmpStreamStatus::State state, + const std::string& error = ""); + void updateStreamStats(const std::string& streamId); + + // OBS callbacks + static void outputStartCallback(void* data, calldata_t* cd); + static void outputStopCallback(void* data, calldata_t* cd); + static void outputReconnectCallback(void* data, calldata_t* cd); + static void outputReconnectSuccessCallback(void* data, calldata_t* cd); + + // Statistics monitoring + void statsMonitoringThread(); + void collectStreamStats(const std::string& streamId, StreamOutput& streamOutput); + + // Helper methods + std::string getOutputName(const std::string& streamId) const; + std::string getServiceName(const std::string& streamId) const; + std::string getVideoEncoderName(const std::string& streamId) const; + std::string getAudioEncoderName(const std::string& streamId) const; + // Select best Twitch server from OBS service list + std::string getRecommendedTwitchServer() const; + + obs_data_t* createServiceSettings(const OneSevenLiveMultiRtmpConfig& config) const; + obs_data_t* createOutputSettings(const OneSevenLiveMultiRtmpConfig& config) const; + obs_data_t* createVideoEncoderSettings(const OneSevenLiveMultiRtmpConfig& config) const; + obs_data_t* createAudioEncoderSettings(const OneSevenLiveMultiRtmpConfig& config) const; + + // Async platform resolution + void resolvePlatformServerKeyAsync(const std::string& streamId, + const OneSevenLiveMultiRtmpConfig& config); + void finalizeServiceSetupAfterResolve(const std::string& streamId, const std::string& server, + const std::string& key); + + // Helper methods for getting OBS default encoder settings + obs_data_t* getObsDefaultVideoEncoderSettings() const; + obs_data_t* getObsDefaultAudioEncoderSettings() const; + const char* getObsDefaultVideoEncoderId() const; + + // Member variables + std::map> m_streamOutputs; + mutable std::mutex m_outputsMutex; + + // Pending async resolution clients per stream (Twitch only) + std::map> m_pendingTwitchClients; + + // Shared encoders + obs_encoder_t* m_sharedVideoEncoder = nullptr; + std::map m_sharedAudioEncoders; + + // Callbacks + StreamStatusCallback m_statusCallback; + StreamStatsCallback m_statsCallback; + mutable std::mutex m_callbackMutex; + + // Statistics monitoring + std::atomic m_statsMonitoringActive{false}; + std::thread m_statsThread; + std::mutex m_statsThreadMutex; + std::atomic m_shuttingDown{false}; + + // Constants + static constexpr int STATS_UPDATE_INTERVAL_MS = 1000; + static constexpr int CONNECT_TIMEOUT_MS = 60000; + static constexpr const char* OUTPUT_ID = "rtmp_output"; + static constexpr const char* SERVICE_ID = "rtmp_common"; + static constexpr const char* VIDEO_ENCODER_ID = "obs_x264"; + static constexpr const char* AUDIO_ENCODER_ID = "ffmpeg_aac"; +}; + +// Logging macros for stream controller +#define MULTI_RTMP_STREAM_LOG(level, format, ...) \ + obs_log(level, "[MultiRTMP-Stream] " format, ##__VA_ARGS__) + +#define MULTI_RTMP_STREAM_LOG_INFO(format, ...) \ + MULTI_RTMP_STREAM_LOG(LOG_INFO, format, ##__VA_ARGS__) + +#define MULTI_RTMP_STREAM_LOG_WARNING(format, ...) \ + MULTI_RTMP_STREAM_LOG(LOG_WARNING, format, ##__VA_ARGS__) + +#define MULTI_RTMP_STREAM_LOG_ERROR(format, ...) \ + MULTI_RTMP_STREAM_LOG(LOG_ERROR, format, ##__VA_ARGS__) + +#define MULTI_RTMP_STREAM_LOG_DEBUG(format, ...) \ + MULTI_RTMP_STREAM_LOG(LOG_DEBUG, format, ##__VA_ARGS__) +class QTimer; diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.cpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.cpp new file mode 100644 index 0000000..dfd3a18 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.cpp @@ -0,0 +1,1424 @@ +#include "OneSevenLiveMultiRtmpConfigDialog.hpp" + +// OBS headers are included in the source to avoid transitive system headers in the dialog header +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveConfigManager.hpp" +#include "OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveHttpServer.hpp" +#include "moc_OneSevenLiveMultiRtmpConfigDialog.cpp" +#include "multi-rtmp/OneSevenLiveMultiRtmpManager.hpp" +#include "twitch/OneSevenLiveTwitchAuth.hpp" +#include "ui/OneSevenLiveAuthDialog.hpp" +#include "ui/OneSevenLivePropertiesWidget.hpp" +#include "utility/Common.hpp" +#include "youtube/OneSevenLiveYouTubeAuth.hpp" + +OneSevenLiveMultiRtmpConfigDialog::OneSevenLiveMultiRtmpConfigDialog( + QWidget* parent, std::shared_ptr config, bool isEditMode) + : QDialog(parent), + m_config(config), + m_mainLayout(nullptr), + m_tabWidget(nullptr), + m_isEditMode(isEditMode), + m_advancedExpanded(false), + m_baseHeight(0), + m_isAuthorizing(false) { + setWindowTitle(QString::fromUtf8(obs_module_text("MultiRTMP.Config.Title"))); + setModal(true); + + // Set dialog size constraints to match reference style + setMinimumSize(350, 525); // Increased minimum width to accommodate content + setMaximumSize(600, 900); // Increased maximum width for better content display + resize(400, 450); // Set initial size to ensure content fits properly + + setupUI(); + + // Use CoreManager-owned authorization handlers to keep unified state + OneSevenLiveCoreManager& coreManager = OneSevenLiveCoreManager::getInstance(); + m_twitchAuth = coreManager.getTwitchAuth(); + m_youtubeAuth = coreManager.getYouTubeAuth(); + + // Load configuration if provided + if (m_config) { + // duplicate config to m_origConfig + m_originalConfig = std::make_shared(*m_config); + + // Load configuration + loadConfig(); + } + + // Record the base height after UI setup (when advanced settings are collapsed) + // Use a small delay to ensure layout is fully calculated + QTimer::singleShot(0, [this]() { m_baseHeight = height(); }); + + setupConnections(); +} + +OneSevenLiveMultiRtmpConfigDialog::~OneSevenLiveMultiRtmpConfigDialog() { + // Disconnect all QComboBox signals to prevent crashes during destruction + if (m_streamNameCombo) { + disconnect(m_streamNameCombo, nullptr, this, nullptr); + } + if (m_protocolCombo) { + disconnect(m_protocolCombo, nullptr, this, nullptr); + } + if (m_outputSceneCombo) { + disconnect(m_outputSceneCombo, nullptr, this, nullptr); + } + if (m_videoEncoderCombo) { + disconnect(m_videoEncoderCombo, nullptr, this, nullptr); + } + if (m_audioEncoderCombo) { + disconnect(m_audioEncoderCombo, nullptr, this, nullptr); + } + + if (m_tmpServiceProps) { + obs_service_t* svc = static_cast(m_tmpServiceProps); + obs_service_release(svc); + m_tmpServiceProps = nullptr; + } +} + +void OneSevenLiveMultiRtmpConfigDialog::setupUI() { + // Create main layout for the dialog + m_mainLayout = new QVBoxLayout(this); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); + + // Create scroll area + m_scrollArea = new QScrollArea(this); + QScrollArea* scrollArea = m_scrollArea; + scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + scrollArea->setWidgetResizable(true); // Allow content resizing + scrollArea->setFrameShape(QFrame::NoFrame); // Remove border + scrollArea->setVerticalScrollBarPolicy( + Qt::ScrollBarAsNeeded); // Show vertical scrollbar when needed + scrollArea->setHorizontalScrollBarPolicy( + Qt::ScrollBarAlwaysOff); // Disable horizontal scrollbar + + // Create container widget for scroll area content + m_container = new QWidget(this); + QWidget* container = m_container; + container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + container->setStyleSheet( + "QWidget {" + " color: white;" + " font-family: 'Inter';" + " font-style: normal;" + "}" + "QToolTip {" + " background-color: #333333;" + " color: #FFFFFF;" + " font-weight: 400;" + " font-size: 12px;" + " line-height: 16px;" + " padding: 5px;" + " border: none;" + " border-radius: 4px;" + "}"); + + QVBoxLayout* containerLayout = new QVBoxLayout(container); + containerLayout->setContentsMargins( + 20, 16, 20, 16); // Increased horizontal margins for better content spacing + containerLayout->setSpacing(16); + + // Top section: Basic information (name, protocol, URL, stream key) + setupBasicInfoSection(); + + // Advanced settings button + setupAdvancedSettingsButton(); + + // Advanced settings widget (collapsible) + setupAdvancedSettingsWidget(); + + // Add sections to container layout + containerLayout->addWidget(m_basicInfoWidget); + containerLayout->addWidget(m_advancedButton); + containerLayout->addWidget(m_advancedWidget); + containerLayout->addStretch(); // Add stretch to push content to top + + // Set container as scroll area content + scrollArea->setWidget(container); + + // Bottom section: Button box (outside scroll area) + setupButtonBox(); + + // Add scroll area and button layout to main layout + m_mainLayout->addWidget(scrollArea); + m_mainLayout->addLayout(m_buttonLayout); + + loadEncoders(); + loadScenes(); +} + +void OneSevenLiveMultiRtmpConfigDialog::resizeEvent(QResizeEvent* event) { + QDialog::resizeEvent(event); + int avail = width(); + if (m_scrollArea && m_scrollArea->viewport()) { + avail = m_scrollArea->viewport()->width(); + } + if (m_advancedWidget) + m_advancedWidget->setMaximumWidth(avail); + if (m_serviceWidget) + m_serviceWidget->setMaximumWidth(avail); + if (m_tabWidget) + m_tabWidget->setMaximumWidth(avail); + if (m_outputTab) + m_outputTab->setMaximumWidth(avail); + if (m_videoTab) + m_videoTab->setMaximumWidth(avail); + if (m_videoWidget) + m_videoWidget->setMaximumWidth(avail); + if (m_audioTab) + m_audioTab->setMaximumWidth(avail); + if (m_audioWidget) + m_audioWidget->setMaximumWidth(avail); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupBasicInfoSection() { + m_basicInfoWidget = new QWidget(); + m_basicInfoLayout = new QFormLayout(m_basicInfoWidget); + + // Set form layout properties to match reference style + m_basicInfoLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); + m_basicInfoLayout->setLabelAlignment(Qt::AlignLeft); + m_basicInfoLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + m_basicInfoLayout->setSpacing(12); + m_basicInfoLayout->setContentsMargins(0, 0, 0, 0); + + // RTMP channel selection (stored in config.streamName) + QLabel* streamNameLabel = new QLabel(); + streamNameLabel->setText( + QString("*%1") + .arg(obs_module_text("MultiRtmp.Config.StreamName"))); + m_streamNameCombo = new QComboBox(); + bool hasYouTube = false; + bool hasTwitch = false; + if (auto mgr = OneSevenLiveMultiRtmpManager::getInstance()) { + auto configs = mgr->getAllStreamConfigs(); + obs_log(LOG_INFO, "MultiRtmp: %d stream configs loaded", configs.size()); + for (const auto& cfg : configs) { + // log stream name + obs_log(LOG_INFO, "Stream name: %s", cfg.streamName.c_str()); + if (cfg.streamName == "YouTube") + hasYouTube = true; + else if (cfg.streamName == "Twitch") + hasTwitch = true; + } + } + obs_log(LOG_INFO, "isEditMode: %s", m_isEditMode ? "true" : "false"); + obs_log(LOG_INFO, "hasYouTube: %s", hasYouTube ? "true" : "false"); + obs_log(LOG_INFO, "hasTwitch: %s", hasTwitch ? "true" : "false"); + if (m_isEditMode && m_config) { + m_streamNameCombo->addItem(QString::fromStdString(m_config->streamName)); + m_streamNameCombo->setEnabled(false); + } else { + // if (!hasYouTube) + // m_streamNameCombo->addItem("YouTube"); + if (!hasTwitch) + m_streamNameCombo->addItem("Twitch"); + } + m_streamNameCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_basicInfoLayout->addRow(streamNameLabel, m_streamNameCombo); + + // Authorize login button (YouTube/Twitch) + m_authorizeButton = new QPushButton(obs_module_text("MultiRtmp.Config.Authorize")); + m_basicInfoLayout->addRow(m_authorizeButton); + + // Initialize authorize button state based on current selection and token validity + // The state will be updated again after config load and when selection changes + updateAuthorizeButtonState(); + + connect(m_authorizeButton, &QPushButton::clicked, this, [this]() { + const QString channel = m_streamNameCombo ? m_streamNameCombo->currentText() : QString(); + bool isAuthorized = false; + if (channel == OneSevenLiveYouTubeAuth::PLATFORM) { + isAuthorized = (m_youtubeAuth && m_youtubeAuth->hasValidToken()); + if (isAuthorized && m_youtubeAuth) { + m_youtubeAuth->clearToken(); + if (auto* cm = OneSevenLiveCoreManager::getInstance().getConfigManager()) { + cm->clearYouTubeAccessToken(); + cm->clearYouTubeRefreshToken(); + } + } else if (!isAuthorized) { + onAuthorizeClicked(); + } + } else if (channel == OneSevenLiveTwitchAuth::PLATFORM) { + isAuthorized = (m_twitchAuth && m_twitchAuth->hasValidToken()); + if (isAuthorized && m_twitchAuth) { + m_twitchAuth->clearTokens(); + if (auto* cm = OneSevenLiveCoreManager::getInstance().getConfigManager()) { + cm->clearTwitchTokens(); + cm->clearTwitchUserInfo(); + } + } else if (!isAuthorized) { + onAuthorizeClicked(); + } + } + updateAuthorizeButtonState(); + }); + + // Protocol dropdown - only RTMP, SRT/RIST, WHIP + QLabel* protocolLabel = new QLabel(); + protocolLabel->setText(QString("%1") + .arg(obs_module_text("MultiRtmp.Config.Protocol"))); + + m_protocolCombo = new QComboBox(); + + // Populate protocol combo box from the protocol list + const OneSevenLiveProtocol* protocols = getProtocolList(); + size_t protocolCount = getProtocolCount(); + + for (size_t i = 0; i < protocolCount; ++i) { + m_protocolCombo->addItem(protocols[i].label, protocols[i].protocol); + } + + // Set default to first protocol (RTMP) + if (protocolCount > 0) { + m_protocolCombo->setCurrentIndex(0); + } + + m_protocolCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_basicInfoLayout->addRow(protocolLabel, m_protocolCombo); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupAdvancedSettingsButton() { + m_advancedButton = new QPushButton(obs_module_text("MultiRtmp.Config.AdvancedSettings")); + m_advancedButton->setStyleSheet( + "QPushButton { " + " background-color: transparent; " + " border: none;" + " color: white; " + " font-weight: bold; " + "} " + "QPushButton:hover { " + " border: none;" + " background-color: transparent; " + "}"); + + // Set arrow icon for collapsed state + QIcon downIcon(":/resources/arrow-down.svg"); + m_advancedButton->setIcon(downIcon); + m_advancedButton->setIconSize(QSize(12, 12)); + + m_advancedButton->setLayoutDirection(Qt::RightToLeft); // Icon on the right, centered layout +} + +void OneSevenLiveMultiRtmpConfigDialog::setupAdvancedSettingsWidget() { + m_advancedWidget = new QWidget(this); + m_advancedWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + m_advancedWidget->setMinimumWidth(0); + m_advancedWidget->setVisible(false); // Initially collapsed + + QVBoxLayout* advancedLayout = new QVBoxLayout(m_advancedWidget); + advancedLayout->setContentsMargins(0, 10, 0, 0); + advancedLayout->setSpacing(10); + + m_serviceWidget = new OneSevenLivePropertiesWidget(m_advancedWidget); + m_serviceWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + m_serviceWidget->setMinimumWidth(0); + + advancedLayout->addWidget(m_serviceWidget); + + // Set initial visibility based on current platform authorization state + if (m_streamNameCombo) { + const QString channelSel = m_streamNameCombo->currentText(); + bool isAuthorized = false; + if (channelSel == "YouTube") { + isAuthorized = (m_youtubeAuth && m_youtubeAuth->hasValidToken()); + } else if (channelSel == "Twitch") { + isAuthorized = (m_twitchAuth && m_twitchAuth->hasValidToken()); + } + m_serviceWidget->setVisible(!isAuthorized); + } + + // Create tab widget for advanced settings + m_tabWidget = new QTabWidget(m_advancedWidget); + m_tabWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Expanding); + m_tabWidget->setMinimumWidth(0); + + setupOutputTab(); + setupVideoTab(); + setupAudioTab(); + + advancedLayout->addWidget(m_tabWidget); +} + +// Service tab removed - integrated into basic info section + +void OneSevenLiveMultiRtmpConfigDialog::setupOutputTab() { + m_outputTab = new QWidget(m_tabWidget); + m_outputTab->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + m_outputTab->setMinimumWidth(0); + m_outputLayout = new QFormLayout(m_outputTab); + m_outputLayout->setSpacing(12); + m_outputLayout->setContentsMargins(8, 12, 8, 12); + m_outputLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); + m_outputLayout->setLabelAlignment(Qt::AlignLeft); + m_outputLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + m_outputWidget = new OneSevenLivePropertiesWidget(m_outputTab); + + m_tabWidget->addTab(m_outputWidget, obs_module_text("MultiRTMP.Config.Tab.Output")); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupVideoTab() { + m_videoTab = new QWidget(m_tabWidget); + m_videoTab->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + m_videoTab->setMinimumWidth(0); + m_videoLayout = new QFormLayout(m_videoTab); + m_videoLayout->setSpacing(12); + m_videoLayout->setContentsMargins(8, 12, 8, 12); + m_videoLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); + m_videoLayout->setLabelAlignment(Qt::AlignLeft); + m_videoLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + m_outputSceneCombo = new QComboBox(m_videoTab); + m_videoLayout->addRow(obs_module_text("MultiRtmp.Config.Video.OutputScene"), + m_outputSceneCombo); + + m_videoEncoderCombo = new QComboBox(m_videoTab); + m_videoLayout->addRow(obs_module_text("MultiRTMP.Config.Encoder.Video"), m_videoEncoderCombo); + + m_videoWidget = new OneSevenLivePropertiesWidget(m_videoTab); + m_videoLayout->addWidget(m_videoWidget); + + m_tabWidget->addTab(m_videoTab, obs_module_text("MultiRTMP.Config.Tab.Video")); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupAudioTab() { + m_audioTab = new QWidget(m_tabWidget); + m_audioLayout = new QFormLayout(m_audioTab); + m_audioLayout->setSpacing(12); + m_audioLayout->setContentsMargins(8, 12, 8, 12); + m_audioLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); + m_audioLayout->setLabelAlignment(Qt::AlignLeft); + m_audioLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + m_audioEncoderCombo = new QComboBox(m_audioTab); + m_audioLayout->addRow(obs_module_text("MultiRTMP.Config.Encoder.Audio"), m_audioEncoderCombo); + + m_audioWidget = new OneSevenLivePropertiesWidget(m_audioTab); + m_audioLayout->addWidget(m_audioWidget); + + m_tabWidget->addTab(m_audioTab, obs_module_text("MultiRTMP.Config.Tab.Audio")); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupButtonBox() { + m_buttonLayout = new QHBoxLayout(); + m_buttonLayout->setSpacing(12); + m_buttonLayout->setContentsMargins(16, 16, 16, 16); + + m_cancelButton = new QPushButton(obs_module_text("MultiRtmp.Config.Cancel")); + m_cancelButton->setFixedHeight(32); + m_cancelButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_cancelButton->setStyleSheet("background-color: #666666; color: white;"); + + m_okButton = new QPushButton(obs_module_text("MultiRtmp.Config.Confirm")); + m_okButton->setFixedHeight(32); + m_okButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_okButton->setStyleSheet("background-color: #FF0001; color: white;"); + m_okButton->setDefault(true); + + m_buttonLayout->addWidget(m_cancelButton, 1); + m_buttonLayout->addWidget(m_okButton, 2); +} + +void OneSevenLiveMultiRtmpConfigDialog::setupConnections() { + // Advanced settings toggle + connect(m_advancedButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpConfigDialog::onAdvancedSettingsToggled); + + // Buttons + connect(m_okButton, &QPushButton::clicked, this, &OneSevenLiveMultiRtmpConfigDialog::accept); + connect(m_cancelButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpConfigDialog::reject); + + // Update authorize button whenever channel selection changes + connect(m_streamNameCombo, &QComboBox::currentTextChanged, this, + [this](const QString&) { updateAuthorizeButtonState(); }); + + connect(m_streamNameCombo, &QComboBox::currentTextChanged, this, [this](const QString& text) { + const char* svc = (text == "YouTube") ? "YouTube - RTMPS" : "Twitch"; + ObsDataPtr s{obs_data_create()}; + obs_data_set_string(s.get(), "service", svc); + obs_service_t* tmp = + obs_service_create("rtmp_common", "temp_service_refresh", s.get(), nullptr); + s.reset(); + if (tmp) { + obs_data_t* st = obs_service_get_settings(tmp); + obs_properties_t* pr = obs_service_properties(tmp); + if (st && pr && m_serviceWidget) { + m_serviceWidget->UpdateProperties(st, pr); + } else { + if (pr) + obs_properties_destroy(pr); + if (st) + obs_data_release(st); + } + obs_service_release(tmp); + } + if (text == "Twitch") { + proc_handler_t* ph = obs_get_proc_handler(); + calldata_t cd; + calldata_init(&cd); + calldata_set_int(&cd, "seconds", 10); + proc_handler_call(ph, "twitch_ingests_refresh", &cd); + calldata_free(&cd); + } + }); + + // Authorization failure handling + if (m_twitchAuth) { + connect(m_twitchAuth, &OneSevenLiveTwitchAuth::authorizationFailed, this, + &OneSevenLiveMultiRtmpConfigDialog::onAuthorizationFailed); + } + if (m_youtubeAuth) { + connect(m_youtubeAuth, &OneSevenLiveYouTubeAuth::authorizationFailed, this, + &OneSevenLiveMultiRtmpConfigDialog::onAuthorizationFailed); + } + + if (m_videoEncoderCombo) { + connect(m_videoEncoderCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { refreshVideoEncoderProperties(); }); + } + if (m_audioEncoderCombo) { + connect(m_audioEncoderCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { refreshAudioEncoderProperties(); }); + } +} + +void OneSevenLiveMultiRtmpConfigDialog::setEditMode(bool isEdit) { + m_isEditMode = isEdit; +} + +void OneSevenLiveMultiRtmpConfigDialog::accept() { + // Note: SaveConfig() is called by the parent dialog (OneSevenLiveMultiRtmpDock) + // to avoid double calls and potential memory issues + obs_log(LOG_INFO, + "[MultiRTMP-ConfigDialog] Dialog accepted, SaveConfig will be called by parent"); + QDialog::accept(); +} + +void OneSevenLiveMultiRtmpConfigDialog::reject() { + QDialog::reject(); +} + +// Service-related slot functions removed - functionality integrated into basic info section + +void OneSevenLiveMultiRtmpConfigDialog::onAdvancedSettingsToggled() { + m_advancedExpanded = !m_advancedExpanded; + m_advancedWidget->setVisible(m_advancedExpanded); + + // Update button icon based on expanded state + if (m_advancedExpanded) { + QIcon upIcon(":/resources/arrow-up.svg"); + m_advancedButton->setIcon(upIcon); + } else { + QIcon downIcon(":/resources/arrow-down.svg"); + m_advancedButton->setIcon(downIcon); + } + + // Adjust dialog size properly + if (m_advancedExpanded) { + // When expanding, calculate the needed height for advanced settings + // Use a small delay to ensure the widget visibility change is processed + QTimer::singleShot(0, [this]() { + int currentWidth = width(); + int neededHeight = sizeHint().height(); + + // Ensure we don't shrink below the base height + if (neededHeight < m_baseHeight) { + neededHeight = m_baseHeight + 200; // Add some space for advanced settings + } + + resize(currentWidth, neededHeight); + }); + } else { + // When collapsing, return to base height + if (m_baseHeight > 0) { + resize(width(), m_baseHeight); + } else { + // Fallback if base height wasn't recorded properly + resize(width(), 400); + } + } +} + +void OneSevenLiveMultiRtmpConfigDialog::onAuthorizeClicked() { + // Determine selected RTMP channel + QString channel = m_streamNameCombo->currentText(); + + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Authorize clicked for channel: %s", + channel.isEmpty() ? "(none)" : channel.toUtf8().constData()); + if (channel != OneSevenLiveTwitchAuth::PLATFORM && + channel != OneSevenLiveYouTubeAuth::PLATFORM) { + obs_log(LOG_WARNING, "Unknown type authorization cancelled"); + return; + } + + if (m_isAuthorizing) { + obs_log(LOG_WARNING, "Authorization already in progress"); + return; + } + + m_isAuthorizing = true; + m_lastAuthError.clear(); + + QString authUrl; + + if (channel == OneSevenLiveTwitchAuth::PLATFORM) { + // Handle Twitch authorization using device code flow + QString redirectUri = OneSevenLiveTwitchAuth::TWITCH_CALLBACK_URI; + authUrl = m_twitchAuth->getAuthUrl(redirectUri); + obs_log(LOG_INFO, "Opening Twitch authorization URL: %s", authUrl.toStdString().c_str()); + } else if (channel == OneSevenLiveYouTubeAuth::PLATFORM) { + // Build YouTube authorization URL (authorization code flow) + OneSevenLiveCoreManager& coreManager = OneSevenLiveCoreManager::getInstance(); + QString redirectUri = + QString("http://localhost:%1").arg(coreManager.getHttpServer()->getPort()); + authUrl = m_youtubeAuth->getAuthUrl(redirectUri); + obs_log(LOG_INFO, "Opening YouTube authorization URL: %s", authUrl.toStdString().c_str()); + } + + // Show authorization dialog with embedded browser + m_authDialog = new OneSevenLiveAuthDialog(authUrl, this); + // Don't use DeleteOnClose, we will delete it manually after exec() returns + // m_authDialog->setAttribute(Qt::WA_DeleteOnClose, true); + + connect(m_authDialog, &OneSevenLiveAuthDialog::urlChanged, this, + &OneSevenLiveMultiRtmpConfigDialog::onAuthUrlChanged); + + m_authDialog->exec(); + + // Explicitly delete the dialog + delete m_authDialog; + m_authDialog = nullptr; + m_isAuthorizing = false; + + // Check if there was an error during authorization (deferred display) + if (!m_lastAuthError.isEmpty()) { + // Use a 0-timer to allow the parent dialog to regain focus/activation properly + // before showing the error message. This prevents the message box from being + // obscured by the parent dialog on some platforms (macOS). + QString error = m_lastAuthError; + QTimer::singleShot(0, this, [this, error]() { + QMessageBox::warning( + this, QString::fromUtf8(obs_module_text("MultiRTMP.AuthorizationFailed.Title")), + QString::fromUtf8(obs_module_text("MultiRTMP.AuthorizationFailed.Text")).arg(error), + QMessageBox::Ok); + updateAuthorizeButtonState(); + }); + m_lastAuthError.clear(); + } +} + +void OneSevenLiveMultiRtmpConfigDialog::onAuthorizationFailed(const QString& error) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] Authorization failed: %s", + error.toUtf8().constData()); + + m_lastAuthError = error; + + // Close auth dialog if it's open + if (m_authDialog) { + // Just reject/close the dialog. Deletion is handled in onAuthorizeClicked after exec() + // returns. + m_authDialog->reject(); + } else { + // If dialog is not open (unlikely in this flow), show error immediately + // Use singleShot to ensure proper z-ordering + QTimer::singleShot(0, this, [this, error]() { + QMessageBox::warning( + this, QString::fromUtf8(obs_module_text("MultiRTMP.AuthorizationFailed.Title")), + QString::fromUtf8(obs_module_text("MultiRTMP.AuthorizationFailed.Text")).arg(error), + QMessageBox::Ok); + updateAuthorizeButtonState(); + }); + m_lastAuthError.clear(); + } + + m_isAuthorizing = false; +} + +void OneSevenLiveMultiRtmpConfigDialog::onAuthUrlChanged(const QString& url) { + QString channel = m_streamNameCombo->currentText(); + + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] %s auth URL changed: %s", + channel.toUtf8().constData(), url.toUtf8().constData()); + + QString redirectUrl; + if (channel == OneSevenLiveTwitchAuth::PLATFORM) { + redirectUrl = OneSevenLiveTwitchAuth::TWITCH_CALLBACK_URI; + } else if (channel == OneSevenLiveYouTubeAuth::PLATFORM) { + redirectUrl = m_youtubeAuth->getRedirectUri(); + } + + // ignore the url not same with redirect url + if (!url.startsWith(redirectUrl)) { + return; + } + + if (channel == OneSevenLiveTwitchAuth::PLATFORM) { + m_twitchAuth->handleAuthorizationCallbackUrl(url); + } else if (channel == OneSevenLiveYouTubeAuth::PLATFORM) { + m_youtubeAuth->handleAuthorizationCallbackUrl(url); + } + + if (m_authDialog) { + QMetaObject::invokeMethod(m_authDialog, "accept", Qt::QueuedConnection); + } + + // Update button state after potential token change + updateAuthorizeButtonState(); +} + +void OneSevenLiveMultiRtmpConfigDialog::loadConfig() { + if (!m_config) { + return; + } + + // Load basic information + // Map existing streamName to combo selection if matches supported channels + if (!m_config->streamName.empty()) { + const QString name = QString::fromStdString(m_config->streamName); + int idx = m_streamNameCombo->findText(name, Qt::MatchFixedString); + if (idx >= 0) { + m_streamNameCombo->setCurrentIndex(idx); + } + // Ensure authorize button reflects token state for the selected channel + updateAuthorizeButtonState(); + } + + // Load protocol and URL + auto protocol_info = findProtocol(m_config->protocol); + if (!protocol_info) { + obs_log(LOG_ERROR, "[loadConfig] Failed to find protocol info for: %s", + m_config->protocol.c_str()); + return; + } + + // Set protocol in combo box + for (int i = 0; i < m_protocolCombo->count(); ++i) { + if (m_protocolCombo->itemData(i).toString().toStdString() == m_config->protocol) { + m_protocolCombo->setCurrentIndex(i); + break; + } + } + + // Load service settings + { + ObsDataPtr service_settings{nullptr}; + if (!m_config->serviceSettings.empty()) { + service_settings.reset( + obs_data_create_from_json(m_config->serviceSettings.dump().c_str())); + } + + obs_service_t* service = obs_service_create(protocol_info->serviceId, "temp_service", + service_settings.get(), nullptr); + if (!service) { + obs_log(LOG_ERROR, "[loadConfig] Failed to create OBS service with ID: %s", + protocol_info->serviceId); + service_settings.reset(); + return; + } + + obs_data_t* settings = obs_service_get_settings(service); + // Pre-select service based on current channel to ensure proper server list + if (settings && m_streamNameCombo) { + const QString channel = m_streamNameCombo->currentText(); + const char* svcName = (channel == "YouTube") ? "YouTube - RTMPS" : "Twitch"; + obs_data_set_string(settings, "service", svcName); + obs_service_update(service, settings); + } + obs_properties_t* props = obs_service_properties(service); + + if (!settings || !props) { + obs_log(LOG_ERROR, "[loadConfig] Failed to get service settings or properties"); + obs_service_release(service); + service_settings.reset(); + return; + } + + if (m_serviceWidget) { + m_serviceWidget->UpdateProperties(settings, props); + } + + // Ownership of 'settings' and 'props' is transferred to m_serviceWidget + obs_service_release(service); + service_settings.reset(); + } + + // Load output settings + { + ObsDataPtr output_settings{nullptr}; + if (!m_config->outputSettings.empty()) { + output_settings.reset( + obs_data_create_from_json(m_config->outputSettings.dump().c_str())); + } + + obs_output_t* output = obs_output_create(protocol_info->outputId, "temp_output", + output_settings.get(), nullptr); + if (!output) { + obs_log(LOG_ERROR, "[loadConfig] Failed to create OBS output with ID: %s", + protocol_info->outputId); + output_settings.reset(); + return; + } + + obs_data_t* settings = obs_output_get_settings(output); + obs_properties_t* props = obs_output_properties(output); + + if (!settings || !props) { + obs_log(LOG_ERROR, + "[loadConfig] Failed to get output settings or properties (settings: %p, " + "props: %p)", + (void*) settings, (void*) props); + obs_output_release(output); + output_settings.reset(); + return; + } + + if (!m_outputWidget) { + obs_log(LOG_ERROR, "[loadConfig] m_outputWidget is null, cannot update properties"); + // Ownership of 'settings' and 'props' is transferred to m_outputWidget + obs_output_release(output); + output_settings.reset(); + return; + } + + try { + m_outputWidget->UpdateProperties(settings, props); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[loadConfig] Exception in UpdateProperties: %s", e.what()); + } catch (...) { + obs_log(LOG_ERROR, "[loadConfig] Unknown exception in UpdateProperties"); + } + + // Ownership of 'settings' and 'props' is transferred to m_outputWidget + obs_output_release(output); + output_settings.reset(); + } + + // Load video encoder selection and settings + if (m_config->videoConfig.has_value()) { + // Select encoder in combo (non-empty means custom encoder; empty means Use OBS) + if (m_videoEncoderCombo) { + const QString encId = QString::fromStdString(m_config->videoConfig->encoderId); + if (!encId.isEmpty()) { + int idx = m_videoEncoderCombo->findData(encId); + if (idx >= 0) + m_videoEncoderCombo->setCurrentIndex(idx); + } else { + m_videoEncoderCombo->setCurrentIndex(0); + } + } + + if (!m_config->videoConfig->encoderSettings.empty()) { + ObsDataPtr encoder_settings{ + obs_data_create_from_json(m_config->videoConfig->encoderSettings.dump().c_str())}; + + obs_encoder_t* encoder = + obs_video_encoder_create(m_config->videoConfig->encoderId.c_str(), + "temp_video_encoder", encoder_settings.get(), nullptr); + if (!encoder) { + obs_log(LOG_ERROR, "[loadConfig] Failed to create video encoder with ID: %s", + m_config->videoConfig->encoderId.c_str()); + encoder_settings.reset(); + // Fall back to refreshing default properties if encoder cannot be created + refreshVideoEncoderProperties(); + } else { + obs_data_t* settings = obs_encoder_get_settings(encoder); + obs_properties_t* props = obs_encoder_properties(encoder); + + if (!settings || !props) { + obs_log(LOG_ERROR, + "[loadConfig] Failed to get video encoder settings or properties " + "(settings: " + "%p, props: %p)", + (void*) settings, (void*) props); + obs_encoder_release(encoder); + encoder_settings.reset(); + refreshVideoEncoderProperties(); + } else { + if (!m_videoWidget) { + obs_log( + LOG_ERROR, + "[loadConfig] m_videoWidget is null, cannot update video properties"); + // Ownership of 'settings' and 'props' is transferred to m_videoWidget + obs_encoder_release(encoder); + encoder_settings.reset(); + refreshVideoEncoderProperties(); + } else { + try { + m_videoWidget->UpdateProperties(settings, props); + m_videoWidget->setVisible(true); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, + "[loadConfig] Exception in video UpdateProperties: %s", + e.what()); + } catch (...) { + obs_log(LOG_ERROR, + "[loadConfig] Unknown exception in video UpdateProperties"); + } + + // Ownership of 'settings' and 'props' is transferred to m_videoWidget + obs_encoder_release(encoder); + encoder_settings.reset(); + } + } + } + } else { + // No saved settings; just refresh properties for selected encoder + refreshVideoEncoderProperties(); + } + // Ensure properties reflect the selected encoder state + if (m_videoEncoderCombo && m_videoEncoderCombo->currentData().toString().isEmpty()) { + if (m_videoWidget) + m_videoWidget->setVisible(false); + } else { + if (m_videoWidget) + m_videoWidget->setVisible(true); + } + } else { + // No videoConfig present → Use OBS + if (m_videoEncoderCombo) + m_videoEncoderCombo->setCurrentIndex(0); + if (m_videoWidget) + m_videoWidget->setVisible(false); + } + + // Load audio encoder selection and settings + if (m_config->audioConfig.has_value()) { + if (m_audioEncoderCombo) { + const QString encId = QString::fromStdString(m_config->audioConfig->encoderId); + if (!encId.isEmpty()) { + int idx = m_audioEncoderCombo->findData(encId); + if (idx >= 0) + m_audioEncoderCombo->setCurrentIndex(idx); + } else { + m_audioEncoderCombo->setCurrentIndex(0); + } + } + + if (!m_config->audioConfig->encoderSettings.empty()) { + ObsDataPtr encoder_settings{ + obs_data_create_from_json(m_config->audioConfig->encoderSettings.dump().c_str())}; + + obs_encoder_t* encoder = + obs_audio_encoder_create(m_config->audioConfig->encoderId.c_str(), + "temp_audio_encoder", encoder_settings.get(), 0, nullptr); + if (!encoder) { + obs_log(LOG_ERROR, "[loadConfig] Failed to create audio encoder with ID: %s", + m_config->audioConfig->encoderId.c_str()); + encoder_settings.reset(); + refreshAudioEncoderProperties(); + } else { + obs_data_t* settings = obs_encoder_get_settings(encoder); + obs_properties_t* props = obs_encoder_properties(encoder); + + if (!settings || !props) { + obs_log(LOG_ERROR, + "[loadConfig] Failed to get audio encoder settings or properties " + "(settings: " + "%p, props: %p)", + (void*) settings, (void*) props); + obs_encoder_release(encoder); + encoder_settings.reset(); + refreshAudioEncoderProperties(); + } else { + if (!m_audioWidget) { + obs_log( + LOG_ERROR, + "[loadConfig] m_audioWidget is null, cannot update audio properties"); + // Ownership of 'settings' and 'props' is transferred to m_audioWidget + obs_encoder_release(encoder); + encoder_settings.reset(); + refreshAudioEncoderProperties(); + } else { + try { + m_audioWidget->UpdateProperties(settings, props); + m_audioWidget->setVisible(true); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, + "[loadConfig] Exception in audio UpdateProperties: %s", + e.what()); + } catch (...) { + obs_log(LOG_ERROR, + "[loadConfig] Unknown exception in audio UpdateProperties"); + } + + // Ownership of 'settings' and 'props' is transferred to m_audioWidget + obs_encoder_release(encoder); + encoder_settings.reset(); + } + } + } + } else { + refreshAudioEncoderProperties(); + } + if (m_audioEncoderCombo && m_audioEncoderCombo->currentData().toString().isEmpty()) { + if (m_audioWidget) + m_audioWidget->setVisible(false); + } else { + if (m_audioWidget) + m_audioWidget->setVisible(true); + } + } else { + if (m_audioEncoderCombo) + m_audioEncoderCombo->setCurrentIndex(0); + if (m_audioWidget) + m_audioWidget->setVisible(false); + } +} + +// Helper to set authorize button text/enabled based on token validity of selected channel +void OneSevenLiveMultiRtmpConfigDialog::updateAuthorizeButtonState() { + if (!m_authorizeButton) { + return; + } + + const QString channel = m_streamNameCombo ? m_streamNameCombo->currentText() : QString(); + + bool isAuthorized = false; + if (channel == "YouTube") { + isAuthorized = (m_youtubeAuth && m_youtubeAuth->hasValidToken()); + } else if (channel == "Twitch") { + isAuthorized = (m_twitchAuth && m_twitchAuth->hasValidToken()); + } + + if (isAuthorized) { + m_authorizeButton->setText(obs_module_text("MultiRtmp.Config.Deauthorize")); + m_authorizeButton->setEnabled(true); + if (m_serviceWidget) + m_serviceWidget->setVisible(false); + } else { + m_authorizeButton->setText(obs_module_text("MultiRtmp.Config.Authorize")); + m_authorizeButton->setEnabled(true); + if (m_serviceWidget) + m_serviceWidget->setVisible(true); + } +} + +OneSevenLiveMultiRtmpConfig OneSevenLiveMultiRtmpConfigDialog::SaveConfig() const { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] SaveConfig called - starting configuration save"); + + try { + OneSevenLiveMultiRtmpConfig config; + + // Set ID based on edit mode - only preserve existing ID for edit mode + if (m_isEditMode && m_config) { + config.id = m_config->id; + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Edit mode: using existing ID: %s", + config.id.c_str()); + } else { + // For new configurations, leave ID empty - it will be generated by the manager + config.id = ""; + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] New mode: ID will be generated by manager"); + } + + // Basic configuration with null checks + if (m_isEditMode && m_config) { + config.streamName = m_config->streamName; + } else { + if (!m_streamNameCombo) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_streamNameCombo is null"); + throw std::runtime_error("Stream name combo widget is null"); + } + config.streamName = m_streamNameCombo->currentText().toStdString(); + } + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] RTMP channel (stream name): '%s'", + config.streamName.c_str()); + + if (!m_protocolCombo) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_protocolCombo is null"); + throw std::runtime_error("Protocol combo widget is null"); + } + config.protocol = m_protocolCombo->currentData().toString().toStdString(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Protocol: '%s'", config.protocol.c_str()); + + // Service configuration + if (!m_serviceWidget) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_serviceWidget is null"); + throw std::runtime_error("Service widget is null"); + } + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving service settings..."); + config.serviceSettings = m_serviceWidget->SaveData(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Service settings saved successfully"); + + // Output configuration + if (!m_outputWidget) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_outputWidget is null"); + throw std::runtime_error("Output widget is null"); + } + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving output settings..."); + config.outputSettings = m_outputWidget->SaveData(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Output settings saved successfully"); + + // Video configuration + QString vidId = + m_videoEncoderCombo ? m_videoEncoderCombo->currentData().toString() : QString(); + bool useObsVideo = vidId.isEmpty(); + if (!useObsVideo) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving custom video configuration..."); + OneSevenLiveMultiRtmpVideoConfig vcfg; + + vcfg.encoderId = vidId.toStdString(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Video encoder ID: '%s'", + vcfg.encoderId.c_str()); + + if (m_outputSceneCombo) { + const QVariant data = m_outputSceneCombo->currentData(); + vcfg.outputScene = data.isValid() ? data.toString().toStdString() + : m_outputSceneCombo->currentText().toStdString(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Output scene: '%s'", + vcfg.outputScene.c_str()); + } + + if (!m_videoWidget) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_videoWidget is null"); + vcfg.encoderSettings = nlohmann::json::object(); + } else { + vcfg.encoderSettings = m_videoWidget->SaveData(); + } + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving video encoder settings..."); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Video encoder settings saved successfully"); + + config.videoConfig = vcfg; + } else { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Using OBS video settings"); + config.videoConfig.reset(); + } + + // Audio configuration + QString audId = + m_audioEncoderCombo ? m_audioEncoderCombo->currentData().toString() : QString(); + bool useObsAudio = audId.isEmpty(); + if (!useObsAudio) { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving custom audio configuration..."); + OneSevenLiveMultiRtmpAudioConfig acfg; + + acfg.encoderId = audId.toStdString(); + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Audio encoder ID: '%s'", + acfg.encoderId.c_str()); + + if (!m_audioWidget) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] m_audioWidget is null"); + acfg.encoderSettings = nlohmann::json::object(); + } else { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Saving audio encoder settings..."); + acfg.encoderSettings = m_audioWidget->SaveData(); + obs_log(LOG_INFO, + "[MultiRTMP-ConfigDialog] Audio encoder settings saved successfully"); + } + + config.audioConfig = acfg; + } else { + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] Using OBS audio settings"); + config.audioConfig.reset(); + } + + obs_log(LOG_INFO, "[MultiRTMP-ConfigDialog] SaveConfig completed successfully"); + return config; + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] SaveConfig failed with exception: %s", + e.what()); + throw; + } catch (...) { + obs_log(LOG_ERROR, "[MultiRTMP-ConfigDialog] SaveConfig failed with unknown exception"); + throw; + } +} + +void OneSevenLiveMultiRtmpConfigDialog::loadScenes() { + if (!m_outputSceneCombo) + return; + + m_outputSceneCombo->clear(); + m_outputSceneCombo->addItem(obs_module_text("MultiRtmp.Config.Video.UseOBS"), ""); + + using EnumParam = std::vector; + EnumParam scenes; + + obs_enum_scenes( + [](void* p, obs_source_t* src) { + auto* list = static_cast(p); + const char* name = obs_source_get_name(src); + if (name && *name) + list->emplace_back(name); + return true; + }, + &scenes); + + for (const auto& name : scenes) { + m_outputSceneCombo->addItem(name.c_str(), name.c_str()); + } +} + +std::vector OneSevenLiveMultiRtmpConfigDialog::parseAndLoadEncoders( + const std::string& supportedEncoders, bool isVideoEncoder) { + std::vector encoderIds; + + if (!supportedEncoders.empty()) { + // Split the semicolon-separated string + std::string encoders = supportedEncoders; + size_t pos = 0; + std::string token; + + while ((pos = encoders.find(';')) != std::string::npos) { + token = encoders.substr(0, pos); + if (!token.empty()) { + // Query OBS API for encoders supporting this codec + size_t i = 0; + for (;;) { + const char* encid; + if (!obs_enum_encoder_types(i++, &encid)) + break; + auto caps = obs_get_encoder_caps(encid); + if (caps & OBS_ENCODER_CAP_DEPRECATED) + continue; + + // Check if this is the correct encoder type (video or audio) + auto enc_type = obs_get_encoder_type(encid); + bool isCorrectType = isVideoEncoder ? (enc_type == OBS_ENCODER_VIDEO) + : (enc_type == OBS_ENCODER_AUDIO); + if (!isCorrectType) + continue; + + auto enc_codec = obs_get_encoder_codec(encid); + if (strcmp(enc_codec, token.c_str()) == 0) { + encoderIds.emplace_back(encid); + } + } + } + encoders.erase(0, pos + 1); + } + + // Handle the last token (after the last semicolon or if no semicolon exists) + if (!encoders.empty()) { + size_t i = 0; + for (;;) { + const char* encid; + if (!obs_enum_encoder_types(i++, &encid)) + break; + auto caps = obs_get_encoder_caps(encid); + if (caps & OBS_ENCODER_CAP_DEPRECATED) + continue; + + // Check if this is the correct encoder type (video or audio) + auto enc_type = obs_get_encoder_type(encid); + bool isCorrectType = isVideoEncoder ? (enc_type == OBS_ENCODER_VIDEO) + : (enc_type == OBS_ENCODER_AUDIO); + if (!isCorrectType) + continue; + + auto enc_codec = obs_get_encoder_codec(encid); + if (strcmp(enc_codec, encoders.c_str()) == 0) { + encoderIds.emplace_back(encid); + } + } + } + } + + return encoderIds; +} + +void OneSevenLiveMultiRtmpConfigDialog::loadEncoders() { + auto ui_text = [](const std::string& id) { + const char* dn = obs_encoder_get_display_name(id.c_str()); + if (!dn) + dn = id.c_str(); + return std::string(dn) + " [" + id + "]"; + }; + + // Query current OBS outputs to provide "SameAsOBS" placeholders + const char* streamingVideoId = nullptr; + const char* streamingAudioId = nullptr; + + if (obs_output_t* streaming = obs_frontend_get_streaming_output()) { + if (obs_encoder_t* ve = obs_output_get_video_encoder(streaming)) + streamingVideoId = obs_encoder_get_id(ve); + if (obs_encoder_t* ae = obs_output_get_audio_encoder(streaming, 0)) + streamingAudioId = obs_encoder_get_id(ae); + obs_output_release(streaming); + } + + // Video encoders + if (m_videoEncoderCombo) { + QVariant old = m_videoEncoderCombo->currentData(); + m_videoEncoderCombo->clear(); + m_videoEncoderCombo->addItem(obs_module_text("MultiRtmp.Config.Video.UseOBS"), + streamingVideoId ? streamingVideoId : ""); + + QString channelSel = m_streamNameCombo ? m_streamNameCombo->currentText() : QString(); + const char* svcName = (channelSel == "YouTube") ? "YouTube - RTMPS" : "Twitch"; + ObsDataPtr s{obs_data_create()}; + obs_data_set_string(s.get(), "service", svcName); + obs_service_t* tmp = + obs_service_create("rtmp_common", "temp_codec_service_v", s.get(), nullptr); + s.reset(); + std::set vset; + const char** vcodecs = nullptr; + if (tmp) + vcodecs = obs_service_get_supported_video_codecs(tmp); + if (vcodecs) { + for (size_t i = 0; vcodecs[i]; ++i) + vset.insert(vcodecs[i]); + } else { + const char* list = obs_get_output_supported_video_codecs("rtmp_output"); + if (list && *list) { + std::string l(list); + size_t pos; + while ((pos = l.find(';')) != std::string::npos) { + std::string tok = l.substr(0, pos); + if (!tok.empty()) + vset.insert(tok); + l.erase(0, pos + 1); + } + if (!l.empty()) + vset.insert(l); + } + } + + size_t i = 0; + const char* encId = nullptr; + while (obs_enum_encoder_types(i++, &encId)) { + if (!encId) + continue; + if (obs_get_encoder_type(encId) != OBS_ENCODER_VIDEO) + continue; + uint32_t caps = obs_get_encoder_caps(encId); + if (caps & OBS_ENCODER_CAP_DEPRECATED) + continue; + const char* codec = obs_get_encoder_codec(encId); + if (!codec || vset.find(codec) == vset.end()) + continue; + m_videoEncoderCombo->addItem(ui_text(encId).c_str(), QString::fromUtf8(encId)); + } + if (tmp) + obs_service_release(tmp); + int idx = m_videoEncoderCombo->findData(old); + if (idx >= 0) + m_videoEncoderCombo->setCurrentIndex(idx); + refreshVideoEncoderProperties(); + } + + // Audio encoders + if (m_audioEncoderCombo) { + QVariant old = m_audioEncoderCombo->currentData(); + m_audioEncoderCombo->clear(); + m_audioEncoderCombo->addItem(obs_module_text("MultiRtmp.Config.Audio.UseOBS"), + streamingAudioId ? streamingAudioId : ""); + + QString channelSelA = m_streamNameCombo ? m_streamNameCombo->currentText() : QString(); + const char* svcNameA = (channelSelA == "YouTube") ? "YouTube - RTMPS" : "Twitch"; + ObsDataPtr sa{obs_data_create()}; + obs_data_set_string(sa.get(), "service", svcNameA); + obs_service_t* tmpa = + obs_service_create("rtmp_common", "temp_codec_service_a", sa.get(), nullptr); + sa.reset(); + std::set aset; + const char** acodecs = nullptr; + if (tmpa) + acodecs = obs_service_get_supported_audio_codecs(tmpa); + if (acodecs) { + for (size_t i2 = 0; acodecs[i2]; ++i2) + aset.insert(acodecs[i2]); + } else { + const char* list = obs_get_output_supported_audio_codecs("rtmp_output"); + if (list && *list) { + std::string l(list); + size_t pos; + while ((pos = l.find(';')) != std::string::npos) { + std::string tok = l.substr(0, pos); + if (!tok.empty()) + aset.insert(tok); + l.erase(0, pos + 1); + } + if (!l.empty()) + aset.insert(l); + } + } + + size_t j = 0; + const char* aeId = nullptr; + while (obs_enum_encoder_types(j++, &aeId)) { + if (!aeId) + continue; + if (obs_get_encoder_type(aeId) != OBS_ENCODER_AUDIO) + continue; + uint32_t caps = obs_get_encoder_caps(aeId); + if (caps & OBS_ENCODER_CAP_DEPRECATED) + continue; + const char* codec = obs_get_encoder_codec(aeId); + if (!codec || aset.find(codec) == aset.end()) + continue; + m_audioEncoderCombo->addItem(ui_text(aeId).c_str(), QString::fromUtf8(aeId)); + } + if (tmpa) + obs_service_release(tmpa); + int idx = m_audioEncoderCombo->findData(old); + if (idx >= 0) + m_audioEncoderCombo->setCurrentIndex(idx); + refreshAudioEncoderProperties(); + } +} + +void OneSevenLiveMultiRtmpConfigDialog::refreshVideoEncoderProperties() { + if (!m_videoWidget || !m_videoEncoderCombo) + return; + QString id = m_videoEncoderCombo->currentData().toString(); + if (id.isEmpty()) { + m_videoWidget->setVisible(false); + return; + } + m_videoWidget->setVisible(true); + ObsDataPtr initSettings{obs_data_create()}; + obs_encoder_t* enc = obs_video_encoder_create( + id.toUtf8().constData(), "temp_video_encoder_props", initSettings.get(), nullptr); + initSettings.reset(); + if (!enc) + return; + obs_data_t* settings = obs_encoder_get_settings(enc); + obs_properties_t* props = obs_encoder_properties(enc); + if (settings && props) { + m_videoWidget->UpdateProperties(settings, props); + } + obs_encoder_release(enc); +} + +void OneSevenLiveMultiRtmpConfigDialog::refreshAudioEncoderProperties() { + if (!m_audioWidget || !m_audioEncoderCombo) + return; + QString id = m_audioEncoderCombo->currentData().toString(); + if (id.isEmpty()) { + m_audioWidget->setVisible(false); + return; + } + m_audioWidget->setVisible(true); + ObsDataPtr initSettings{obs_data_create()}; + obs_encoder_t* enc = obs_audio_encoder_create( + id.toUtf8().constData(), "temp_audio_encoder_props", initSettings.get(), 0, nullptr); + initSettings.reset(); + if (!enc) + return; + obs_data_t* settings = obs_encoder_get_settings(enc); + obs_properties_t* props = obs_encoder_properties(enc); + if (settings && props) { + m_audioWidget->UpdateProperties(settings, props); + } + obs_encoder_release(enc); +} diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.hpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.hpp new file mode 100644 index 0000000..0c85d86 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpConfigDialog.hpp @@ -0,0 +1,157 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "multi-rtmp/OneSevenLiveMultiRtmpModels.hpp" + +// Forward declarations +class OneSevenLivePropertiesWidget; +class OneSevenLiveTwitchAuth; +class OneSevenLiveYouTubeAuth; +class OneSevenLiveAuthDialog; + +/** + * Configuration dialog for Multi-RTMP stream settings + * Provides comprehensive configuration interface with multiple tabs + */ +class OneSevenLiveMultiRtmpConfigDialog : public QDialog { + Q_OBJECT + + public: + explicit OneSevenLiveMultiRtmpConfigDialog( + QWidget* parent = nullptr, std::shared_ptr config = nullptr, + bool isEditMode = false); + ~OneSevenLiveMultiRtmpConfigDialog(); + + // Dialog modes + void setEditMode(bool isEdit); + + bool isEditMode() const { + return m_isEditMode; + } + + // Configuration access + OneSevenLiveMultiRtmpConfig SaveConfig() const; + + public slots: + void accept() override; + void reject() override; + + private slots: + void onAdvancedSettingsToggled(); + void onAuthorizeClicked(); + void onAuthorizationFailed(const QString& error); + + private: + void setupUI(); + void setupBasicInfoSection(); + void setupAdvancedSettingsButton(); + void setupAdvancedSettingsWidget(); + void setupOutputTab(); + void setupVideoTab(); + void setupAudioTab(); + void setupButtonBox(); + + void setupConnections(); + + void loadEncoders(); + void refreshVideoEncoderProperties(); + void refreshAudioEncoderProperties(); + void loadScenes(); + void loadConfig(); + + // Update authorize button based on selected channel token validity + void updateAuthorizeButtonState(); + + // Helper function to parse and load encoders for both video and audio + std::vector parseAndLoadEncoders(const std::string& supportedEncoders, + bool isVideoEncoder); + + std::shared_ptr m_config; + std::shared_ptr m_originalConfig; + + std::string m_supportedVideoEncoders; + std::string m_supportedAudioEncoders; + + // Main layout + QVBoxLayout* m_mainLayout = nullptr; + QTabWidget* m_tabWidget = nullptr; + QScrollArea* m_scrollArea{nullptr}; + QWidget* m_container{nullptr}; + + // Basic info section + QWidget* m_basicInfoWidget = nullptr; + QFormLayout* m_basicInfoLayout = nullptr; + QComboBox* m_streamNameCombo = nullptr; + QPushButton* m_authorizeButton = nullptr; + QComboBox* m_protocolCombo = nullptr; + OneSevenLivePropertiesWidget* m_serviceWidget = nullptr; + + // Advanced settings section + QPushButton* m_advancedButton = nullptr; + QWidget* m_advancedWidget = nullptr; + bool m_advancedExpanded = false; + int m_baseHeight = 0; + + // Output tab + QWidget* m_outputTab = nullptr; + QFormLayout* m_outputLayout = nullptr; + OneSevenLivePropertiesWidget* m_outputWidget = nullptr; + + // Video tab + QWidget* m_videoTab = nullptr; + QFormLayout* m_videoLayout = nullptr; + QComboBox* m_videoEncoderCombo = nullptr; + QComboBox* m_outputSceneCombo = nullptr; + OneSevenLivePropertiesWidget* m_videoWidget = nullptr; + + // Audio tab + QWidget* m_audioTab = nullptr; + QFormLayout* m_audioLayout = nullptr; + QComboBox* m_audioEncoderCombo = nullptr; + OneSevenLivePropertiesWidget* m_audioWidget = nullptr; + + // Button box + QHBoxLayout* m_buttonLayout = nullptr; + QPushButton* m_okButton = nullptr; + QPushButton* m_cancelButton = nullptr; + + // State + bool m_isEditMode; + + bool m_isAuthorizing; + // Twitch authorization (non-owning; managed by CoreManager) + OneSevenLiveTwitchAuth* m_twitchAuth{nullptr}; + // YouTube authorization (non-owning; managed by CoreManager) + OneSevenLiveYouTubeAuth* m_youtubeAuth{nullptr}; + + // Private slots for authorization + void onAuthUrlChanged(const QString& url); + + OneSevenLiveAuthDialog* m_authDialog{nullptr}; + QString m_lastAuthError; + + void* m_tmpServiceProps{nullptr}; + void resizeEvent(QResizeEvent* event) override; +}; diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.cpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.cpp new file mode 100644 index 0000000..e44b058 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.cpp @@ -0,0 +1,730 @@ +#include "OneSevenLiveMultiRtmpDock.hpp" + +#include +#include +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveMultiRtmpConfigDialog.hpp" +#include "OneSevenLiveMultiRtmpListWidget.hpp" +#include "streaming/OneSevenLiveStreamManager.hpp" + +OneSevenLiveMultiRtmpDock::OneSevenLiveMultiRtmpDock(QWidget* parent) + : QDockWidget(obs_module_text("MultiRTMP.Dock.Title"), parent), + m_manager(OneSevenLiveMultiRtmpManager::getInstance()), + m_isFirstShow(true), + m_isUpdatingUI(false) { + obs_log(LOG_INFO, + "[MultiRTMP-Dock] Manager instance obtained, initialization will be done on first use"); + + setupUI(); + setupConnections(); + setupManagerCallbacks(); +} + +OneSevenLiveMultiRtmpDock::~OneSevenLiveMultiRtmpDock() { + if (m_statsUpdateTimer) { + if (m_statsUpdateTimer->isActive()) { + m_statsUpdateTimer->stop(); + } + } + + if (m_manager && OneSevenLiveMultiRtmpManager::peekInstance()) { + m_manager->setStreamStatusCallback(nullptr); + m_manager->setStreamStatsCallback(nullptr); + m_manager->setConfigChangeCallback(nullptr); + m_manager->setConfigDeleteCallback(nullptr); + } + + if (m_configDialog) { + m_configDialog->deleteLater(); + } +} + +void OneSevenLiveMultiRtmpDock::setupUI() { + // Create central widget + m_centralWidget = new QWidget(this); + setWidget(m_centralWidget); + + // Main layout + m_mainLayout = new QVBoxLayout(m_centralWidget); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); + + // Stream list section (top part) + m_scrollArea = new QScrollArea(); + m_scrollArea->setWidgetResizable(true); + m_scrollArea->setFrameStyle(QFrame::NoFrame); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + m_streamListWidget = new OneSevenLiveMultiRtmpListWidget(); + m_streamListWidget->setManager(m_manager); + m_scrollArea->setWidget(m_streamListWidget); + + // Control section (bottom part) + m_controlFrame = new QFrame(); + m_controlFrame->setFrameStyle(QFrame::NoFrame); + m_controlLayout = new QVBoxLayout(m_controlFrame); + m_controlLayout->setContentsMargins(8, 8, 8, 8); + m_controlLayout->setSpacing(8); + + // Top row: Add stream button + m_addStreamButton = new QPushButton(obs_module_text("MultiRTMP.AddStream")); + m_addStreamButton->setMinimumHeight(40); + m_addStreamButton->setStyleSheet( + "QPushButton {" + " background-color: #FF0001;" + " color: white;" + " font-weight: bold;" + " border: none;" + " border-radius: 4px;" + "}" + "QPushButton:hover {" + " background-color: #E60001;" + "}" + "QPushButton:pressed {" + " background-color: #CC0001;" + "}" + "QPushButton:disabled {" + " background-color: #999999;" + "}"); + + // Bottom row: Start All and Stop All buttons + QHBoxLayout* bottomButtonLayout = new QHBoxLayout(); + bottomButtonLayout->setSpacing(8); + + m_startAllButton = new QPushButton(obs_module_text("MultiRTMP.Dock.StartAll")); + m_startAllButton->setMinimumHeight(40); + m_startAllButton->setStyleSheet( + "QPushButton {" + " background-color: #FF0001;" + " color: white;" + " font-weight: bold;" + " border: none;" + " border-radius: 4px;" + "}" + "QPushButton:hover {" + " background-color: #E60001;" + "}" + "QPushButton:pressed {" + " background-color: #CC0001;" + "}" + "QPushButton:disabled {" + " background-color: #999999;" + "}"); + + m_stopAllButton = new QPushButton(obs_module_text("MultiRTMP.Dock.StopAll")); + m_stopAllButton->setMinimumHeight(40); + m_stopAllButton->setStyleSheet( + "QPushButton {" + " background-color: #007AFF;" + " color: white;" + " font-weight: bold;" + " border: none;" + " border-radius: 4px;" + "}" + "QPushButton:hover {" + " background-color: #0056CC;" + "}" + "QPushButton:pressed {" + " background-color: #004499;" + "}" + "QPushButton:disabled {" + " background-color: #999999;" + "}"); + + bottomButtonLayout->addWidget(m_startAllButton); + bottomButtonLayout->addWidget(m_stopAllButton); + + // Add buttons to control layout + m_controlLayout->addWidget(m_addStreamButton); + m_controlLayout->addLayout(bottomButtonLayout); + + // Add sections to main layout + m_mainLayout->addWidget(m_scrollArea, 1); // Give scroll area most space + m_mainLayout->addWidget(m_controlFrame); + + // Setup stats update timer + m_statsUpdateTimer = new QTimer(this); + m_statsUpdateTimer->setInterval(1000); // Update every second + connect(m_statsUpdateTimer, &QTimer::timeout, this, + &OneSevenLiveMultiRtmpDock::onStatsUpdateTimer); + m_statsUpdateTimer->start(); +} + +void OneSevenLiveMultiRtmpDock::setupConnections() { + // Control buttons + connect(m_addStreamButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpDock::onAddStreamClicked); + connect(m_startAllButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpDock::onStartAllClicked); + connect(m_stopAllButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpDock::onStopAllClicked); + + // Stream list widget signals + if (m_streamListWidget) { + connect(m_streamListWidget, &OneSevenLiveMultiRtmpListWidget::streamStartRequested, this, + [this](const std::string& streamId) { + if (m_manager) { + m_manager->startStream(streamId); + // Force immediate button state update to ensure UI responsiveness + updateButtonStates(); + } + }); + + connect(m_streamListWidget, &OneSevenLiveMultiRtmpListWidget::streamStopRequested, this, + [this](const std::string& streamId) { + if (m_manager) { + m_manager->stopStream(streamId); + // Update button states immediately since we now use actual stream item + // states + updateButtonStates(); + } + }); + + connect(m_streamListWidget, &OneSevenLiveMultiRtmpListWidget::streamEditRequested, this, + [this](const std::string& streamId) { + if (m_manager) { + auto config = m_manager->getStreamConfig(streamId); + showConfigDialog(config); + } + }); + + connect(m_streamListWidget, &OneSevenLiveMultiRtmpListWidget::streamDeleteRequested, this, + [this](const std::string& streamId) { + auto reply = QMessageBox::question( + this, QString::fromUtf8(obs_module_text("MultiRTMP.Delete.Title")), + QString::fromUtf8(obs_module_text("MultiRTMP.Delete.Confirm")), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply == QMessageBox::Yes && m_manager) { + m_manager->removeStreamConfig(streamId); + onStreamDeleted(streamId); + } + }); + } +} + +bool OneSevenLiveMultiRtmpDock::ensureManagerInitialized() { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Skipping ensureManagerInitialized during shutdown"); + return false; + } + auto* inst = OneSevenLiveMultiRtmpManager::peekInstance(); + if (!inst) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Manager instance not alive (peekInstance=null)"); + return false; + } + if (m_manager != inst) { + m_manager = inst; + } + + if (!m_manager->isInitialized()) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Initializing MultiRTMP manager on first use"); + if (!m_manager->initialize()) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Failed to initialize MultiRTMP manager"); + return false; + } + obs_log(LOG_INFO, "[MultiRTMP-Dock] MultiRTMP manager initialized successfully"); + } + + return true; +} + +void OneSevenLiveMultiRtmpDock::setupManagerCallbacks() { + if (!ensureManagerInitialized()) { + return; + } + + // Set up callbacks for manager events + QPointer self(this); + m_manager->setStreamStatusCallback( + [self](const std::string& streamId, const OneSevenLiveMultiRtmpStreamStatus& status) { + if (!self) + return; + QMetaObject::invokeMethod( + self, + [self, streamId, status]() { + if (!self) + return; + self->updateStreamStatus(streamId, status); + }, + Qt::QueuedConnection); + }); + + m_manager->setStreamStatsCallback( + [self](const std::string& streamId, const OneSevenLiveMultiRtmpStreamStats& stats) { + if (!self) + return; + QMetaObject::invokeMethod( + self, + [self, streamId, stats]() { + if (!self) + return; + self->updateStreamStats(streamId, stats); + }, + Qt::QueuedConnection); + }); + + m_manager->setConfigChangeCallback( + [self](const std::string& streamId, const OneSevenLiveMultiRtmpConfig& config) { + Q_UNUSED(config); + if (!self) + return; + QMetaObject::invokeMethod( + self, + [self, streamId]() { + if (!self) + return; + self->onStreamConfigChanged(streamId); + }, + Qt::QueuedConnection); + }); + + m_manager->setConfigDeleteCallback([self](const std::string& streamId) { + if (!self) + return; + QMetaObject::invokeMethod( + self, + [self, streamId]() { + if (!self) + return; + self->onStreamDeleted(streamId); + }, + Qt::QueuedConnection); + }); +} + +void OneSevenLiveMultiRtmpDock::showEvent(QShowEvent* event) { + QDockWidget::showEvent(event); + + // Only load streams on first show to avoid unnecessary reloads + if (m_isFirstShow) { + obs_log(LOG_INFO, + "[MultiRTMP-Dock] First show event - loading stored stream configurations"); + m_isFirstShow = false; + + // Ensure manager is initialized before loading streams + if (ensureManagerInitialized()) { + refreshStreamList(); + } else { + obs_log(LOG_WARNING, "[MultiRTMP-Dock] Failed to initialize manager during first show"); + } + } +} + +void OneSevenLiveMultiRtmpDock::refreshStreamList() { + obs_log(LOG_INFO, "[MultiRTMP-Dock] refreshStreamList() called"); + + if (!ensureManagerInitialized()) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] refreshStreamList() failed: manager not initialized"); + return; + } + + if (!m_streamListWidget) { + obs_log(LOG_ERROR, + "[MultiRTMP-Dock] refreshStreamList() failed: m_streamListWidget is null"); + return; + } + + if (m_isUpdatingUI) { + obs_log(LOG_WARNING, + "[MultiRTMP-Dock] refreshStreamList() skipped: UI update already in progress"); + return; + } + + try { + m_isUpdatingUI = true; + + // Clear existing streams + m_streamListWidget->clearAllStreams(); + + // Add all configured streams + auto configs = m_manager->getAllStreamConfigs(); + for (size_t i = 0; i < configs.size(); ++i) { + const auto& config = configs[i]; + + try { + std::string n = config.streamName; + std::transform(n.begin(), n.end(), n.begin(), ::tolower); + if (n == std::string("youtube")) + continue; + m_streamListWidget->addStream(config); + + // Update with current status and stats + auto status = m_manager->getStreamStatus(config.id); + auto stats = m_manager->getStreamStats(config.id); + m_streamListWidget->updateStreamStatus(config.id, status); + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Exception while processing stream %s: %s", + config.id.c_str(), e.what()); + // Continue with next stream + } catch (...) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Unknown exception while processing stream %s", + config.id.c_str()); + // Continue with next stream + } + } + + updateButtonStates(); + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Exception in refreshStreamList(): %s", e.what()); + } catch (...) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Unknown exception in refreshStreamList()"); + } + + m_isUpdatingUI = false; +} + +void OneSevenLiveMultiRtmpDock::updateStreamStatus( + const std::string& streamId, const OneSevenLiveMultiRtmpStreamStatus& status) { + // Update stream status in the list widget if not in bulk update mode + if (m_streamListWidget && !m_isUpdatingUI) { + m_streamListWidget->updateStreamStatus(streamId, status); + } else if (m_isUpdatingUI) { + obs_log(LOG_DEBUG, + "[MultiRTMP-Dock] updateStreamStatus skipped for stream %s due to bulk UI update " + "in progress", + streamId.c_str()); + } + + // Always update button states for individual stream status changes + // This ensures "Start All" and "Stop All" buttons reflect current state immediately + updateButtonStates(); +} + +void OneSevenLiveMultiRtmpDock::updateStreamStats(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStats& stats) { + if (m_streamListWidget && !m_isUpdatingUI) { + m_streamListWidget->updateStreamStats(streamId, stats); + } +} + +void OneSevenLiveMultiRtmpDock::onAddStreamClicked() { + showConfigDialog(); +} + +void OneSevenLiveMultiRtmpDock::onStartAllClicked() { + // Pre-check 17LIVE streaming status before starting MultiRTMP + { + auto& core = OneSevenLiveCoreManager::getInstance(); + OneSevenLiveStreamManager* streamMgr = core.getStreamManager(); + if (!streamMgr) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] 17LIVE StreamManager not available"); + QMessageBox::warning(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.StreamManagerUnavailable")); + return; + } + + // 1) If 17live live NOT started + if (!streamMgr->hasActiveLiveStream() || + streamMgr->getCurrentStreamingStatus() == OneSevenLiveStreamingStatus::NotStarted) { + obs_log(LOG_WARNING, "[MultiRTMP-Manager] 17LIVE live not started; blocking MultiRTMP"); + QMessageBox::information(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.LiveNotStarted")); + return; + } + + // Fetch current info to inspect group call flag + const OneSevenLiveStreamInfo& liveInfo = streamMgr->getCurrentLiveStreamInfo(); + const OneSevenLiveRtmpRequest& liveReq = streamMgr->getCurrentStreamRequest(); + const bool isGroupCall = liveReq.enableOBSGroupCall || liveInfo.request.enableOBSGroupCall; + + // 2) If live is groupcall (party live), block + if (isGroupCall) { + obs_log(LOG_WARNING, + "[MultiRTMP-Manager] 17LIVE live is GroupCall; MultiRTMP unsupported"); + QMessageBox::warning(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.GroupCallNotSupported")); + return; + } + + // 3) If live started but not streaming, prompt user to start streaming first + if (streamMgr->getCurrentStreamingStatus() == OneSevenLiveStreamingStatus::Live && + !streamMgr->isOBSStreaming()) { + obs_log(LOG_INFO, + "[MultiRTMP-Manager] 17LIVE live started but OBS not streaming; prompt user"); + QMessageBox::information(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.StartObsStreamingFirst")); + // Do not return here per requirement 3: prompt then continue starting MultiRTMP + } + // 4) If already streaming, proceed directly (no-op) + } + + if (ensureManagerInitialized()) { + m_startAllButton->setEnabled(false); + + m_manager->startAllStreams(); + + // Button states will be updated automatically through status callbacks + // Add a timer to re-enable the button in case callbacks don't come through + QTimer::singleShot(2000, this, [this]() { + if (m_startAllButton && !m_startAllButton->isEnabled()) { + updateButtonStates(); + } + }); + } +} + +void OneSevenLiveMultiRtmpDock::onStopAllClicked() { + if (ensureManagerInitialized()) { + { + auto& core = OneSevenLiveCoreManager::getInstance(); + OneSevenLiveStreamManager* streamMgr = core.getStreamManager(); + if (streamMgr && + streamMgr->getCurrentStreamingStatus() != OneSevenLiveStreamingStatus::NotStarted) { + QMessageBox::information(this, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Stop.InfoTip17LIVE")); + } + } + m_stopAllButton->setEnabled(false); + + QMessageBox::StandardButton ret = QMessageBox::question( + this, QString::fromUtf8(obs_module_text("MultiRTMP.Dock.StopAll")), + QString::fromUtf8(obs_module_text("MultiRTMP.StopAllConfirm")), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (ret != QMessageBox::Yes) { + m_stopAllButton->setEnabled(true); + return; + } + + m_manager->stopAllStreams(); + + // Button states will be updated automatically through status callbacks + // Add a timer to re-enable the button in case callbacks don't come through + QTimer::singleShot(2000, this, [this]() { + if (m_stopAllButton && !m_stopAllButton->isEnabled()) { + updateButtonStates(); + } + }); + } +} + +void OneSevenLiveMultiRtmpDock::onRefreshClicked() { + refreshStreamList(); +} + +void OneSevenLiveMultiRtmpDock::onStreamConfigChanged(const std::string& streamId) { + if (m_streamListWidget) { + // Get the updated config from the manager + if (ensureManagerInitialized()) { + auto config = m_manager->getStreamConfig(streamId); + m_streamListWidget->updateStream(config); + } + } +} + +void OneSevenLiveMultiRtmpDock::onStreamDeleted(const std::string& streamId) { + if (m_streamListWidget) { + m_streamListWidget->removeStream(streamId); + updateButtonStates(); + } +} + +void OneSevenLiveMultiRtmpDock::onStatsUpdateTimer() { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + return; + } + if (!ensureManagerInitialized() || !m_streamListWidget || m_isUpdatingUI) { + return; + } + + // Update stats for all active streams + auto activeIds = m_manager->getActiveStreamIds(); + for (const auto& streamId : activeIds) { + auto stats = m_manager->getStreamStats(streamId); + m_streamListWidget->updateStreamStats(streamId, stats); + } +} + +void OneSevenLiveMultiRtmpDock::updateButtonStates() { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + return; + } + if (!ensureManagerInitialized() || !m_streamListWidget) { + return; + } + + // Get actual status statistics directly from stream items + auto stats = m_streamListWidget->getStreamStatusStats(); + + // Calculate counts for button logic + size_t totalCount = stats.totalCount; + size_t activeCount = stats.activeCount; + size_t inactiveCount = + totalCount - activeCount - stats.connectingCount; // Stopped + Error streams can be started + + obs_log(LOG_INFO, + "[MultiRTMP-Dock] updateButtonStates(): totalCount=%zu, activeCount=%zu, " + "connectingCount=%zu, stoppedCount=%zu, errorCount=%zu", + totalCount, activeCount, stats.connectingCount, stats.stoppedCount, stats.errorCount); + + // Enable/disable buttons based on actual stream item states + m_startAllButton->setEnabled(totalCount > 0 && inactiveCount > 0); + m_stopAllButton->setEnabled(activeCount > 0 || stats.connectingCount > 0); + + // Update button text with actual counts + if (totalCount > 0) { + m_startAllButton->setText( + QString(QString::fromUtf8(obs_module_text("MultiRTMP.Dock.StartAll.WithCount"))) + .arg(inactiveCount)); + m_stopAllButton->setText( + QString(QString::fromUtf8(obs_module_text("MultiRTMP.Dock.StopAll.WithCount"))) + .arg(activeCount + stats.connectingCount)); + } else { + m_startAllButton->setText(QString::fromUtf8(obs_module_text("MultiRTMP.Dock.StartAll"))); + m_stopAllButton->setText(QString::fromUtf8(obs_module_text("MultiRTMP.Dock.StopAll"))); + } + + // Hide Add Stream when both YouTube and Twitch exist + size_t nonYouTubeCount = 0; + if (!OneSevenLiveCoreManager::getInstance().isShuttingDown() && ensureManagerInitialized()) { + auto configs = m_manager->getAllStreamConfigs(); + for (const auto& cfg : configs) { + std::string n = cfg.streamName; + std::transform(n.begin(), n.end(), n.begin(), ::tolower); + if (n != std::string("youtube")) + ++nonYouTubeCount; + } + } + if (m_addStreamButton) + m_addStreamButton->setVisible(nonYouTubeCount == 0); +} + +void OneSevenLiveMultiRtmpDock::showConfigDialog(const OneSevenLiveMultiRtmpConfig& config) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] showConfigDialog() called"); + + try { + // Create dialog with configuration + bool isEdit = !config.id.empty(); + obs_log(LOG_INFO, "[MultiRTMP-Dock] Dialog mode: %s", isEdit ? "edit" : "new"); + + std::shared_ptr configPtr = + std::make_shared(config); + obs_log(LOG_INFO, "[MultiRTMP-Dock] Created config pointer"); + + m_configDialog = new OneSevenLiveMultiRtmpConfigDialog(this, configPtr, isEdit); + if (!m_configDialog) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Failed to create config dialog"); + return; + } + obs_log(LOG_INFO, "[MultiRTMP-Dock] Created config dialog successfully"); + + if (isEdit) { + m_configDialog->setWindowTitle( + QString::fromUtf8(obs_module_text("MultiRTMP.EditStream.Title"))); + } else { + m_configDialog->setWindowTitle( + QString::fromUtf8(obs_module_text("MultiRTMP.AddStream.Title"))); + } + obs_log(LOG_INFO, "[MultiRTMP-Dock] Dialog setup completed"); + + // Show dialog and handle result + obs_log(LOG_INFO, "[MultiRTMP-Dock] Showing dialog"); + if (m_configDialog->exec() == QDialog::Accepted) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Dialog accepted, calling SaveConfig()"); + + try { + auto newConfig = m_configDialog->SaveConfig(); + + // Add detailed logging for configuration data + obs_log(LOG_INFO, "[MultiRTMP-Dock] Configuration dialog accepted"); + obs_log(LOG_INFO, "[MultiRTMP-Dock] Stream name: '%s'", + newConfig.streamName.c_str()); + obs_log(LOG_INFO, "[MultiRTMP-Dock] Config ID from SaveConfig: '%s'", + newConfig.id.c_str()); + + if (ensureManagerInitialized()) { + bool success = false; + + if (isEdit) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Updating existing stream config: %s", + config.id.c_str()); + // For edit mode, preserve the original ID + newConfig.id = config.id; + success = m_manager->updateStreamConfig(config.id, newConfig); + } else { + // Generate new ID for new stream only if not already set + if (newConfig.id.empty()) { + newConfig.id = m_manager->generateStreamId(); + obs_log(LOG_INFO, "[MultiRTMP-Dock] Generated new ID for stream: %s", + newConfig.id.c_str()); + } + obs_log(LOG_INFO, "[MultiRTMP-Dock] Adding new stream config with ID: %s", + newConfig.id.c_str()); + + success = m_manager->addStreamConfig(newConfig); + } + + if (success) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Stream configuration %s successfully", + isEdit ? "updated" : "added"); + + try { + if (isEdit) { + obs_log(LOG_INFO, + "[MultiRTMP-Dock] Calling onStreamConfigChanged for: %s", + config.id.c_str()); + onStreamConfigChanged(config.id); + } else { + obs_log( + LOG_INFO, + "[MultiRTMP-Dock] Calling refreshStreamList for new stream"); + refreshStreamList(); + } + obs_log(LOG_INFO, "[MultiRTMP-Dock] UI update completed successfully"); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Exception during UI update: %s", + e.what()); + } catch (...) { + obs_log(LOG_ERROR, + "[MultiRTMP-Dock] Unknown exception during UI update"); + } + } else { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Failed to %s stream configuration", + isEdit ? "update" : "add"); + QMessageBox::warning( + this, QString::fromUtf8(obs_module_text("MultiRTMP.Error.Title")), + isEdit + ? QString::fromUtf8(obs_module_text("MultiRTMP.Error.UpdateFailed")) + : QString::fromUtf8(obs_module_text("MultiRTMP.Error.AddFailed"))); + } + } else { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Manager initialization failed"); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Exception in SaveConfig(): %s", e.what()); + QMessageBox::critical(this, "Error", + QString("Failed to save configuration: %1").arg(e.what())); + } catch (...) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Unknown exception in SaveConfig()"); + QMessageBox::critical(this, "Error", "Failed to save configuration: Unknown error"); + } + } else { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Configuration dialog cancelled"); + } + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Exception in showConfigDialog(): %s", e.what()); + QMessageBox::critical(this, "Error", + QString("Failed to show configuration dialog: %1").arg(e.what())); + } catch (...) { + obs_log(LOG_ERROR, "[MultiRTMP-Dock] Unknown exception in showConfigDialog()"); + QMessageBox::critical(this, "Error", "Failed to show configuration dialog: Unknown error"); + } + + // Clean up dialog + if (m_configDialog) { + obs_log(LOG_INFO, "[MultiRTMP-Dock] Cleaning up config dialog"); + m_configDialog->deleteLater(); + m_configDialog = nullptr; + obs_log(LOG_INFO, "[MultiRTMP-Dock] Config dialog cleaned up successfully"); + } + + obs_log(LOG_INFO, "[MultiRTMP-Dock] showConfigDialog() completed"); +} diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.hpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.hpp new file mode 100644 index 0000000..eb68f94 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpDock.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveMultiRtmpManager.hpp" +#include "plugin-support.h" + +class OneSevenLiveMultiRtmpListWidget; +class OneSevenLiveMultiRtmpConfigDialog; + +/** + * Main dock widget for Multi-RTMP functionality + * Provides the primary UI interface for managing multiple RTMP streams + */ +class OneSevenLiveMultiRtmpDock : public QDockWidget { + Q_OBJECT + + public: + explicit OneSevenLiveMultiRtmpDock(QWidget* parent = nullptr); + ~OneSevenLiveMultiRtmpDock(); + + // Public interface + void refreshStreamList(); + void updateStreamStatus(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStatus& status); + void updateStreamStats(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStats& stats); + + public slots: + void onAddStreamClicked(); + void onStartAllClicked(); + void onStopAllClicked(); + void onRefreshClicked(); + void onStreamConfigChanged(const std::string& streamId); + void onStreamDeleted(const std::string& streamId); + + private slots: + void onStatsUpdateTimer(); + + protected: + void showEvent(QShowEvent* event) override; + + private: + void setupUI(); + void setupConnections(); + void setupManagerCallbacks(); + void updateButtonStates(); + void updateStreamCount(); + void showConfigDialog(const OneSevenLiveMultiRtmpConfig& config = {}); + bool ensureManagerInitialized(); + + // UI components + QWidget* m_centralWidget = nullptr; + QVBoxLayout* m_mainLayout = nullptr; + + // Header section + QPushButton* m_addStreamButton = nullptr; + + // Control section + QFrame* m_controlFrame = nullptr; + QVBoxLayout* m_controlLayout = nullptr; + QPushButton* m_startAllButton = nullptr; + QPushButton* m_stopAllButton = nullptr; + + // Stream list section + QScrollArea* m_scrollArea = nullptr; + OneSevenLiveMultiRtmpListWidget* m_streamListWidget = nullptr; + + // Dialog + QPointer m_configDialog; + + // Timer for periodic updates + QTimer* m_statsUpdateTimer = nullptr; + + // Manager reference + OneSevenLiveMultiRtmpManager* m_manager = nullptr; + + // State management + bool m_isUpdatingUI; + bool m_isFirstShow; +}; diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.cpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.cpp new file mode 100644 index 0000000..7a8d255 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.cpp @@ -0,0 +1,284 @@ +#include "OneSevenLiveMultiRtmpListWidget.hpp" + +#include + +#include "OneSevenLiveMultiRtmpStreamItem.hpp" + +OneSevenLiveMultiRtmpListWidget::OneSevenLiveMultiRtmpListWidget(QWidget* parent) + : QWidget(parent), + m_mainLayout(nullptr), + m_streamLayout(nullptr), + m_streamContainer(nullptr), + m_emptyFrame(nullptr), + m_emptyLayout(nullptr), + m_emptyTextLabel(nullptr), + m_showEmptyState(true), + m_manager(nullptr) { + setupUI(); + updateEmptyState(); +} + +OneSevenLiveMultiRtmpListWidget::~OneSevenLiveMultiRtmpListWidget() { + clearAllStreams(); +} + +void OneSevenLiveMultiRtmpListWidget::setupUI() { + // Main layout + m_mainLayout = new QVBoxLayout(this); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(4); + + // Stream container + m_streamContainer = new QWidget(); + m_streamLayout = new QVBoxLayout(m_streamContainer); + m_streamLayout->setContentsMargins(0, 0, 0, 0); + m_streamLayout->setSpacing(4); + m_streamLayout->addStretch(); // Push items to top + + // Setup empty state + setupEmptyState(); + + // Add to main layout + m_mainLayout->addWidget(m_streamContainer); + m_mainLayout->addWidget(m_emptyFrame); + m_mainLayout->addStretch(); +} + +void OneSevenLiveMultiRtmpListWidget::setupEmptyState() { + m_emptyFrame = new QFrame(); + m_emptyFrame->setStyleSheet( + "QFrame { " + " background-color: transparent; " + " border: none; " + "}"); + + m_emptyLayout = new QVBoxLayout(m_emptyFrame); + m_emptyLayout->setContentsMargins(20, 40, 20, 40); + m_emptyLayout->setSpacing(0); + + // Simple text message + m_emptyTextLabel = new QLabel(obs_module_text("MultiRTMP.List.EmptyTip")); + m_emptyTextLabel->setAlignment(Qt::AlignCenter); + m_emptyTextLabel->setStyleSheet( + "font-size: 16px; " + "color: #999; " + "font-weight: normal;"); + + m_emptyLayout->addStretch(); + m_emptyLayout->addWidget(m_emptyTextLabel); + m_emptyLayout->addStretch(); +} + +void OneSevenLiveMultiRtmpListWidget::addStream(const OneSevenLiveMultiRtmpConfig& config) { + // Check if stream already exists + if (hasStream(config.id)) { + updateStream(config); + return; + } + + // Create new stream item + auto* streamItem = new OneSevenLiveMultiRtmpStreamItem(config, this); + + // Set manager reference + if (m_manager) { + streamItem->setManager(m_manager); + } + + // Connect signals + connect(streamItem, &OneSevenLiveMultiRtmpStreamItem::startRequested, this, + &OneSevenLiveMultiRtmpListWidget::onStreamItemStartClicked); + connect(streamItem, &OneSevenLiveMultiRtmpStreamItem::stopRequested, this, + &OneSevenLiveMultiRtmpListWidget::onStreamItemStopClicked); + connect(streamItem, &OneSevenLiveMultiRtmpStreamItem::editRequested, this, + &OneSevenLiveMultiRtmpListWidget::onStreamItemEditClicked); + connect(streamItem, &OneSevenLiveMultiRtmpStreamItem::deleteRequested, this, + &OneSevenLiveMultiRtmpListWidget::onStreamItemDeleteClicked); + + // Add to layout (before stretch) + int insertIndex = m_streamLayout->count() - 1; // Before stretch + m_streamLayout->insertWidget(insertIndex, streamItem); + + // Add to tracking list + m_streamItems.push_back(streamItem); + + updateEmptyState(); +} + +void OneSevenLiveMultiRtmpListWidget::removeStream(const std::string& streamId) { + auto* item = findStreamItem(streamId); + if (item) { + removeStreamItem(item); + updateEmptyState(); + } +} + +void OneSevenLiveMultiRtmpListWidget::updateStream(const OneSevenLiveMultiRtmpConfig& config) { + auto* item = findStreamItem(config.id); + if (item) { + item->updateConfig(config); + } +} + +void OneSevenLiveMultiRtmpListWidget::updateStreamStatus( + const std::string& streamId, const OneSevenLiveMultiRtmpStreamStatus& status) { + auto* item = findStreamItem(streamId); + if (item) { + item->updateStatus(status); + } +} + +void OneSevenLiveMultiRtmpListWidget::updateStreamStats( + const std::string& streamId, const OneSevenLiveMultiRtmpStreamStats& stats) { + auto* item = findStreamItem(streamId); + if (item) { + item->updateStats(stats); + } +} + +void OneSevenLiveMultiRtmpListWidget::clearAllStreams() { + // Remove all stream items + for (auto* item : m_streamItems) { + m_streamLayout->removeWidget(item); + item->deleteLater(); + } + + m_streamItems.clear(); + updateEmptyState(); +} + +void OneSevenLiveMultiRtmpListWidget::refreshAllStreams() { + // This would typically reload from manager + // For now, just update empty state + updateEmptyState(); +} + +bool OneSevenLiveMultiRtmpListWidget::hasStream(const std::string& streamId) const { + return findStreamItem(streamId) != nullptr; +} + +size_t OneSevenLiveMultiRtmpListWidget::getStreamCount() const { + return m_streamItems.size(); +} + +std::vector OneSevenLiveMultiRtmpListWidget::getAllStreamIds() const { + std::vector ids; + ids.reserve(m_streamItems.size()); + + for (const auto* item : m_streamItems) { + ids.push_back(item->getStreamId()); + } + + return ids; +} + +std::vector OneSevenLiveMultiRtmpListWidget::getActiveStreamIds() const { + std::vector activeIds; + + for (const auto* item : m_streamItems) { + if (item->isActive()) { + activeIds.push_back(item->getStreamId()); + } + } + + return activeIds; +} + +OneSevenLiveMultiRtmpListWidget::StreamStatusStats +OneSevenLiveMultiRtmpListWidget::getStreamStatusStats() const { + StreamStatusStats stats; + stats.totalCount = m_streamItems.size(); + + for (const auto* item : m_streamItems) { + if (!item) + continue; + + const auto& status = item->getStatus(); + switch (status.state) { + case OneSevenLiveMultiRtmpStreamStatus::State::STREAMING: + stats.activeCount++; + break; + case OneSevenLiveMultiRtmpStreamStatus::State::CONNECTING: + case OneSevenLiveMultiRtmpStreamStatus::State::RECONNECTING: + stats.connectingCount++; + break; + case OneSevenLiveMultiRtmpStreamStatus::State::STOPPED: + stats.stoppedCount++; + break; + case OneSevenLiveMultiRtmpStreamStatus::State::ERROR_STATE: + stats.errorCount++; + break; + } + } + + return stats; +} + +void OneSevenLiveMultiRtmpListWidget::setManager(OneSevenLiveMultiRtmpManager* manager) { + m_manager = manager; + + // Update existing stream items + for (auto* item : m_streamItems) { + if (item) { + item->setManager(m_manager); + } + } +} + +void OneSevenLiveMultiRtmpListWidget::onStreamItemStartClicked(const std::string& streamId) { + emit streamStartRequested(streamId); +} + +void OneSevenLiveMultiRtmpListWidget::onStreamItemStopClicked(const std::string& streamId) { + emit streamStopRequested(streamId); +} + +void OneSevenLiveMultiRtmpListWidget::onStreamItemEditClicked(const std::string& streamId) { + emit streamEditRequested(streamId); +} + +void OneSevenLiveMultiRtmpListWidget::onStreamItemDeleteClicked(const std::string& streamId) { + emit streamDeleteRequested(streamId); +} + +void OneSevenLiveMultiRtmpListWidget::onStreamItemDuplicateClicked(const std::string& streamId) { + emit streamDuplicateRequested(streamId); +} + +void OneSevenLiveMultiRtmpListWidget::updateEmptyState() { + bool isEmpty = m_streamItems.empty(); + + if (isEmpty != m_showEmptyState) { + m_showEmptyState = isEmpty; + + m_emptyFrame->setVisible(m_showEmptyState); + m_streamContainer->setVisible(!m_showEmptyState); + } +} + +OneSevenLiveMultiRtmpStreamItem* OneSevenLiveMultiRtmpListWidget::findStreamItem( + const std::string& streamId) const { + auto it = std::find_if(m_streamItems.begin(), m_streamItems.end(), + [&streamId](const OneSevenLiveMultiRtmpStreamItem* item) { + return item && item->getStreamId() == streamId; + }); + + return (it != m_streamItems.end()) ? *it : nullptr; +} + +void OneSevenLiveMultiRtmpListWidget::removeStreamItem(OneSevenLiveMultiRtmpStreamItem* item) { + if (!item) { + return; + } + + // Remove from layout + m_streamLayout->removeWidget(item); + + // Remove from tracking list + auto it = std::find(m_streamItems.begin(), m_streamItems.end(), item); + if (it != m_streamItems.end()) { + m_streamItems.erase(it); + } + + // Delete the widget + item->deleteLater(); +} diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.hpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.hpp new file mode 100644 index 0000000..40f4f58 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpListWidget.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveMultiRtmpModels.hpp" +#include "plugin-support.h" + +class OneSevenLiveMultiRtmpStreamItem; +class OneSevenLiveMultiRtmpManager; + +/** + * List widget for displaying multiple RTMP stream items + * Manages the layout and interaction of individual stream widgets + */ +class OneSevenLiveMultiRtmpListWidget : public QWidget { + Q_OBJECT + + public: + explicit OneSevenLiveMultiRtmpListWidget(QWidget* parent = nullptr); + ~OneSevenLiveMultiRtmpListWidget(); + + // Stream management + void addStream(const OneSevenLiveMultiRtmpConfig& config); + void removeStream(const std::string& streamId); + void updateStream(const OneSevenLiveMultiRtmpConfig& config); + void updateStreamStatus(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStatus& status); + void updateStreamStats(const std::string& streamId, + const OneSevenLiveMultiRtmpStreamStats& stats); + void clearAllStreams(); + void refreshAllStreams(); + + // State queries + bool hasStream(const std::string& streamId) const; + size_t getStreamCount() const; + std::vector getAllStreamIds() const; + std::vector getActiveStreamIds() const; + + // Stream status statistics + struct StreamStatusStats { + size_t totalCount = 0; + size_t activeCount = 0; + size_t connectingCount = 0; + size_t stoppedCount = 0; + size_t errorCount = 0; + }; + + StreamStatusStats getStreamStatusStats() const; + + // Manager access + void setManager(OneSevenLiveMultiRtmpManager* manager); + + signals: + void streamStartRequested(const std::string& streamId); + void streamStopRequested(const std::string& streamId); + void streamEditRequested(const std::string& streamId); + void streamDeleteRequested(const std::string& streamId); + void streamDuplicateRequested(const std::string& streamId); + + private slots: + void onStreamItemStartClicked(const std::string& streamId); + void onStreamItemStopClicked(const std::string& streamId); + void onStreamItemEditClicked(const std::string& streamId); + void onStreamItemDeleteClicked(const std::string& streamId); + void onStreamItemDuplicateClicked(const std::string& streamId); + + private: + void setupUI(); + void setupEmptyState(); + void updateEmptyState(); + OneSevenLiveMultiRtmpStreamItem* findStreamItem(const std::string& streamId) const; + void removeStreamItem(OneSevenLiveMultiRtmpStreamItem* item); + + // UI components + QVBoxLayout* m_mainLayout = nullptr; + QVBoxLayout* m_streamLayout = nullptr; + QWidget* m_streamContainer = nullptr; + + // Empty state + QFrame* m_emptyFrame = nullptr; + QVBoxLayout* m_emptyLayout = nullptr; + QLabel* m_emptyTextLabel = nullptr; + + // Stream items + std::vector m_streamItems; + + // State + bool m_showEmptyState = false; + + // Manager reference + OneSevenLiveMultiRtmpManager* m_manager = nullptr; +}; diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.cpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.cpp new file mode 100644 index 0000000..558b965 --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.cpp @@ -0,0 +1,720 @@ +#include "OneSevenLiveMultiRtmpStreamItem.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveMultiRtmpManager.hpp" +#include "OneSevenLiveCoreManager.hpp" +#include "plugin-support.h" +#include "streaming/OneSevenLiveStreamManager.hpp" +#include "utility/Common.hpp" + +// Static style class constants +const QString OneSevenLiveMultiRtmpStreamItem::STATUS_IDLE_CLASS = "status-idle"; +const QString OneSevenLiveMultiRtmpStreamItem::STATUS_CONNECTING_CLASS = "status-connecting"; +const QString OneSevenLiveMultiRtmpStreamItem::STATUS_ACTIVE_CLASS = "status-active"; +const QString OneSevenLiveMultiRtmpStreamItem::STATUS_ERROR_CLASS = "status-error"; +const QString OneSevenLiveMultiRtmpStreamItem::STATUS_STOPPING_CLASS = "status-stopping"; + +OneSevenLiveMultiRtmpStreamItem::OneSevenLiveMultiRtmpStreamItem( + const OneSevenLiveMultiRtmpConfig& config, QWidget* parent) + : QFrame(parent), + m_config(config), + m_mainLayout(nullptr), + m_topLayout(nullptr), + m_nameLabel(nullptr), + m_statusLayout(nullptr), + m_statusDot(nullptr), + m_statusLabel(nullptr), + m_statsLayout(nullptr), + m_durationLabel(nullptr), + m_bitrateLabel(nullptr), + m_framesLabel(nullptr), + m_controlLayout(nullptr), + m_startStopButton(nullptr), + m_editButton(nullptr), + m_menuButton(nullptr), + m_contextMenu(nullptr), + m_duplicateAction(nullptr), + m_deleteAction(nullptr), + m_statsTimer(nullptr), + m_manager(nullptr), + m_lastTotalBytes(0), + m_lastTotalFrames(0) { + setFrameStyle(QFrame::StyledPanel | QFrame::Raised); + setLineWidth(1); + setMidLineWidth(0); + + setupUI(); + setupContextMenu(); + updateUI(); + + // Setup stats update timer + m_statsTimer = new QTimer(this); + m_statsTimer->setInterval(1000); // Update every second + connect(m_statsTimer, &QTimer::timeout, this, + &OneSevenLiveMultiRtmpStreamItem::onStatsUpdateTimer); + m_statsTimer->start(); +} + +OneSevenLiveMultiRtmpStreamItem::~OneSevenLiveMultiRtmpStreamItem() { + if (m_statsTimer) { + m_statsTimer->stop(); + } + + if (m_contextMenu) { + m_contextMenu->deleteLater(); + } +} + +void OneSevenLiveMultiRtmpStreamItem::setupUI() { + // Main vertical layout (3 layers) + m_mainLayout = new QVBoxLayout(this); + m_mainLayout->setContentsMargins(12, 8, 12, 8); + m_mainLayout->setSpacing(6); + + // Top layer - Name and status + m_topLayout = new QHBoxLayout(); + m_topLayout->setSpacing(8); + + // Stream name (left side) + m_nameLabel = new QLabel(); + m_nameLabel->setStyleSheet( + "font-weight: bold; font-size: 14px; color: #FFFFFF; background-color: transparent;"); + m_nameLabel->setWordWrap(false); + + // Status section (right side) + m_statusLayout = new QHBoxLayout(); + m_statusLayout->setSpacing(6); + m_statusLayout->setAlignment(Qt::AlignRight); + + // Status dot (14px x 14px colored circle) + m_statusDot = new QLabel(); + m_statusDot->setFixedSize(14, 14); + m_statusDot->setStyleSheet("background-color: #A1A9B6; border-radius: 7px;"); + + // Status text + m_statusLabel = new QLabel(); + m_statusLabel->setStyleSheet("font-size: 12px; color: #CCCCCC; background-color: transparent;"); + m_statusLabel->setWordWrap(false); + + m_statusLayout->addWidget(m_statusDot); + m_statusLayout->addWidget(m_statusLabel); + + m_topLayout->addWidget(m_nameLabel, 1); + m_topLayout->addLayout(m_statusLayout, 0); + + // Middle layer - Statistics (vertical stack) + m_statsLayout = new QVBoxLayout(); + m_statsLayout->setSpacing(2); + + // Duration + m_durationLabel = new QLabel("-"); + m_durationLabel->setStyleSheet( + "font-size: 11px; color: #AAAAAA; background-color: transparent;"); + + // Upload speed + m_bitrateLabel = new QLabel("-"); + m_bitrateLabel->setStyleSheet( + "font-size: 11px; color: #AAAAAA; background-color: transparent;"); + + // Frame rate + m_framesLabel = new QLabel("-"); + m_framesLabel->setStyleSheet("font-size: 11px; color: #AAAAAA; background-color: transparent;"); + + m_statsLayout->addWidget(m_durationLabel); + m_statsLayout->addWidget(m_bitrateLabel); + m_statsLayout->addWidget(m_framesLabel); + + // Bottom layer - Controls (horizontal, right-aligned) + m_controlLayout = new QHBoxLayout(); + m_controlLayout->setSpacing(8); + m_controlLayout->setAlignment(Qt::AlignRight); + + m_errorHintLayout = new QHBoxLayout(); + m_errorHintLayout->setSpacing(6); + m_errorHintLayout->setAlignment(Qt::AlignLeft); + m_errorIconLabel = new QLabel(); + m_errorIconLabel->setFixedSize(16, 16); + m_errorIconLabel->setPixmap(QPixmap(":/resources/alert.svg") + .scaled(16, 16, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + m_errorTextLabel = new QLabel(); + m_errorTextLabel->setStyleSheet( + "font-size: 12px; color: #FF873D; background-color: transparent;"); + m_errorHintLayout->addWidget(m_errorIconLabel); + m_errorHintLayout->addWidget(m_errorTextLabel); + + // Play/Stop button + m_startStopButton = new QPushButton(); + m_startStopButton->setMinimumSize(24, 24); + m_startStopButton->setMaximumSize(24, 24); + m_startStopButton->setIcon(QIcon(":/resources/play.svg")); + m_startStopButton->setIconSize(QSize(16, 16)); + m_startStopButton->setStyleSheet( + "QPushButton { border: none; background: transparent; } QPushButton:hover { " + "background-color: rgba(255,255,255,0.1); border-radius: 12px; }"); + m_startStopButton->setToolTip(obs_module_text("MultiRTMP.Start")); + connect(m_startStopButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpStreamItem::onStartStopClicked); + + // Settings button + m_editButton = new QPushButton(); + m_editButton->setMinimumSize(24, 24); + m_editButton->setMaximumSize(24, 24); + m_editButton->setIcon(QIcon(":/resources/settings.svg")); + m_editButton->setIconSize(QSize(16, 16)); + m_editButton->setStyleSheet( + "QPushButton { border: none; background: transparent; } QPushButton:hover { " + "background-color: rgba(255,255,255,0.1); border-radius: 12px; }"); + m_editButton->setToolTip(obs_module_text("MultiRTMP.Edit")); + connect(m_editButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpStreamItem::onEditClicked); + + // Delete button + m_menuButton = new QPushButton(); + m_menuButton->setMinimumSize(24, 24); + m_menuButton->setMaximumSize(24, 24); + m_menuButton->setIcon(QIcon(":/resources/trash.svg")); + m_menuButton->setIconSize(QSize(16, 16)); + m_menuButton->setStyleSheet( + "QPushButton { border: none; background: transparent; } QPushButton:hover { " + "background-color: rgba(255,255,255,0.1); border-radius: 12px; }"); + m_menuButton->setToolTip(obs_module_text("MultiRTMP.Delete")); + connect(m_menuButton, &QPushButton::clicked, this, + &OneSevenLiveMultiRtmpStreamItem::onDeleteAction); + + m_controlLayout->addWidget(m_startStopButton); + m_controlLayout->addWidget(m_editButton); + m_controlLayout->addWidget(m_menuButton); + + // Add all layers to main layout + m_mainLayout->addLayout(m_topLayout); + m_mainLayout->addLayout(m_statsLayout); + QHBoxLayout* bottomRow = new QHBoxLayout(); + bottomRow->setSpacing(8); + bottomRow->addLayout(m_errorHintLayout, 1); + bottomRow->addLayout(m_controlLayout, 0); + m_mainLayout->addLayout(bottomRow); + + // Set minimum height and dark background + setMinimumHeight(170); + setMaximumHeight(170); + setObjectName("multiRtmpItem"); + setAutoFillBackground(true); + setStyleSheet("#multiRtmpItem { background-color: #2D2D30; border-radius: 8px; }"); +} + +void OneSevenLiveMultiRtmpStreamItem::setupContextMenu() { + m_contextMenu = new QMenu(this); + + m_deleteAction = + m_contextMenu->addAction(QApplication::style()->standardIcon(QStyle::SP_TrashIcon), + obs_module_text("MultiRTMP.Delete")); + connect(m_deleteAction, &QAction::triggered, this, + &OneSevenLiveMultiRtmpStreamItem::onDeleteAction); +} + +void OneSevenLiveMultiRtmpStreamItem::updateConfig(const OneSevenLiveMultiRtmpConfig& config) { + m_config = config; + updateUI(); +} + +void OneSevenLiveMultiRtmpStreamItem::updateStatus( + const OneSevenLiveMultiRtmpStreamStatus& status) { + // Record start time when stream becomes active + if (status.state == OneSevenLiveMultiRtmpStreamStatus::STREAMING && + m_status.state != OneSevenLiveMultiRtmpStreamStatus::STREAMING) { + m_startTime = std::chrono::steady_clock::now(); + m_lastStatsTime = m_startTime; + m_lastTotalBytes = 0; + m_lastTotalFrames = 0; + } + + m_status = status; + updateStatusDisplay(); + updateErrorHint(); + updateButtonStates(); // Ensure button states are updated when status changes +} + +void OneSevenLiveMultiRtmpStreamItem::updateStats(const OneSevenLiveMultiRtmpStreamStats& stats) { + m_stats = stats; + updateStatsDisplay(); + updateErrorHint(); +} + +void OneSevenLiveMultiRtmpStreamItem::setManager(OneSevenLiveMultiRtmpManager* manager) { + m_manager = manager; +} + +bool OneSevenLiveMultiRtmpStreamItem::isActive() const { + return m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::STREAMING; +} + +bool OneSevenLiveMultiRtmpStreamItem::isConnecting() const { + return m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::CONNECTING || + m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::RECONNECTING; +} + +bool OneSevenLiveMultiRtmpStreamItem::isError() const { + return m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::ERROR_STATE; +} + +void OneSevenLiveMultiRtmpStreamItem::onStartStopClicked() { + if (isActive() || isConnecting()) { + { + auto& core = OneSevenLiveCoreManager::getInstance(); + OneSevenLiveStreamManager* streamMgr = core.getStreamManager(); + if (streamMgr && + streamMgr->getCurrentStreamingStatus() != OneSevenLiveStreamingStatus::NotStarted) { + QMessageBox::information(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Stop.InfoTip17LIVE")); + } + } + emit stopRequested(m_config.id); + } else { + // Pre-check 17LIVE streaming status before starting MultiRTMP + { + auto& core = OneSevenLiveCoreManager::getInstance(); + OneSevenLiveStreamManager* streamMgr = core.getStreamManager(); + if (!streamMgr) { + obs_log(LOG_ERROR, "[MultiRTMP-Manager] 17LIVE StreamManager not available"); + QMessageBox::warning( + nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.StreamManagerUnavailable")); + return; + } + + // 1) If 17live live NOT started + if (!streamMgr->hasActiveLiveStream() || + streamMgr->getCurrentStreamingStatus() == OneSevenLiveStreamingStatus::NotStarted) { + obs_log(LOG_WARNING, + "[MultiRTMP-Manager] 17LIVE live not started; blocking MultiRTMP %d", + streamMgr->getCurrentStreamingStatus()); + QMessageBox::information(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.LiveNotStarted")); + return; + } + + // Fetch current info to inspect group call flag + const OneSevenLiveStreamInfo& liveInfo = streamMgr->getCurrentLiveStreamInfo(); + const OneSevenLiveRtmpRequest& liveReq = streamMgr->getCurrentStreamRequest(); + const bool isGroupCall = + liveReq.enableOBSGroupCall || liveInfo.request.enableOBSGroupCall; + + // 2) If live is groupcall (party live), block + if (isGroupCall) { + obs_log(LOG_WARNING, + "[MultiRTMP-Manager] 17LIVE live is GroupCall; MultiRTMP unsupported"); + QMessageBox::warning(nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.GroupCallNotSupported")); + return; + } + + // 3) If live started but not streaming, prompt user to start streaming first + if (streamMgr->getCurrentStreamingStatus() == OneSevenLiveStreamingStatus::Live && + !streamMgr->isOBSStreaming()) { + obs_log( + LOG_INFO, + "[MultiRTMP-Manager] 17LIVE live started but OBS not streaming; prompt user"); + QMessageBox::information( + nullptr, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.Precheck.StartObsStreamingFirst")); + // Do not return here per requirement 3: prompt then continue starting MultiRTMP + } + // 4) If already streaming, proceed directly (no-op) + } + + emit startRequested(m_config.id); + } +} + +void OneSevenLiveMultiRtmpStreamItem::onEditClicked() { + emit editRequested(m_config.id); +} + +void OneSevenLiveMultiRtmpStreamItem::onMenuRequested() { + if (m_contextMenu && m_duplicateAction && m_deleteAction && m_menuButton) { + // Update menu state + m_duplicateAction->setEnabled(true); + m_deleteAction->setEnabled(!isActive() && !isConnecting()); + + // Show menu at button position + QPoint globalPos = m_menuButton->mapToGlobal(QPoint(0, m_menuButton->height())); + m_contextMenu->exec(globalPos); + } +} + +void OneSevenLiveMultiRtmpStreamItem::onDeleteAction() { + emit deleteRequested(m_config.id); +} + +void OneSevenLiveMultiRtmpStreamItem::onStatsUpdateTimer() { + if (isActive()) { + collectRealTimeStats(); + } + updateStatsDisplay(); +} + +void OneSevenLiveMultiRtmpStreamItem::updateUI() { + // Update basic info + if (m_nameLabel) { + m_nameLabel->setText(QString::fromStdString(m_config.streamName)); + } + + updateStatusDisplay(); + updateStatusDot(); + updateStatsDisplay(); + updateButtonStates(); + updateErrorHint(); +} + +void OneSevenLiveMultiRtmpStreamItem::updateStatusDisplay() { + // Update status text + QString statusText = getStatusText(); + if (m_statusLabel) { + m_statusLabel->setText(statusText); + } + + // Update status dot color + updateStatusDot(); +} + +void OneSevenLiveMultiRtmpStreamItem::updateStatsDisplay() { + bool isConnected = (m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::STREAMING); + bool isConnPhase = isConnecting(); + auto now = std::chrono::steady_clock::now(); + bool hasRecentStats = + m_lastStatsTime.time_since_epoch().count() != 0 && + std::chrono::duration_cast(now - m_lastStatsTime).count() <= + 1500; + bool showStats = isConnected || isConnPhase || hasRecentStats; + + if (showStats) { + QString duration = formatDuration(static_cast(m_stats.duration.count())); + QString bitrate = formatBitrate(static_cast(m_stats.currentBitrate * 1000)); + QString fps = formatFrameRate(m_stats.currentFPS); + + if (m_durationLabel) + m_durationLabel->setText( + QString("%1: %2").arg(obs_module_text("MultiRTMP.Stats.Duration")).arg(duration)); + if (m_bitrateLabel) + m_bitrateLabel->setText(QString("%1: %2 Kbps") + .arg(obs_module_text("MultiRTMP.Stats.UploadRate")) + .arg(bitrate)); + if (m_framesLabel) + m_framesLabel->setText( + QString("%1: %2 FPS").arg(obs_module_text("MultiRTMP.Stats.FrameRate")).arg(fps)); + } else { + if (m_durationLabel) + m_durationLabel->setText( + QString("%1: --:--:--").arg(obs_module_text("MultiRTMP.Stats.Duration"))); + if (m_bitrateLabel) + m_bitrateLabel->setText( + QString("%1: -- Kbps").arg(obs_module_text("MultiRTMP.Stats.UploadRate"))); + if (m_framesLabel) + m_framesLabel->setText( + QString("%1: -- FPS").arg(obs_module_text("MultiRTMP.Stats.FrameRate"))); + } +} + +void OneSevenLiveMultiRtmpStreamItem::updateErrorHint() { + const bool show = isError(); + if (m_errorIconLabel) + m_errorIconLabel->setVisible(show); + if (m_errorTextLabel) + m_errorTextLabel->setVisible(show); + if (!show) + return; + + QString code = QString::fromStdString(m_status.errorMessage); + QString detail; + if (code.contains(":")) { + detail = code.section(":", 1); + code = code.section(":", 0, 0); + } + ErrorMapping map = mapErrorCode(code); + QString brief = obs_module_text(map.titleKey.toUtf8().constData()); + QString desc = obs_module_text(map.descKey.toUtf8().constData()); + QString solution = obs_module_text(map.solutionKey.toUtf8().constData()); + if (!detail.isEmpty()) { + desc = desc + "\n" + detail; + } + m_errorTextLabel->setText(brief); + m_errorTextLabel->setToolTip(composeErrorTooltip(brief, desc, solution)); +} + +QString OneSevenLiveMultiRtmpStreamItem::composeErrorTooltip(const QString& brief, + const QString& detail, + const QString& solution) const { + QString tip; + tip += brief + "\n\n"; + tip += detail + "\n\n"; + tip += solution; + return tip; +} + +OneSevenLiveMultiRtmpStreamItem::ErrorMapping OneSevenLiveMultiRtmpStreamItem::mapErrorCode( + const QString& code) const { + static QJsonObject cache; + if (cache.isEmpty()) { + std::string dataPath = get_obs_module_data_path_str(); + QString configPath = + QString("%1/multi-rtmp-errors.json").arg(QString::fromStdString(dataPath)); + QFile f(configPath); + if (f.open(QIODevice::ReadOnly)) { + auto doc = QJsonDocument::fromJson(f.readAll()); + if (doc.isObject()) + cache = doc.object(); + f.close(); + } + } + QJsonObject obj = cache.value(code).toObject(); + ErrorMapping m; + m.titleKey = obj.value("title_key").toString("MultiRTMP.ErrTitle.Generic"); + m.descKey = obj.value("desc_key").toString("MultiRTMP.ErrDesc.Generic"); + m.solutionKey = obj.value("solution_key").toString("MultiRTMP.ErrSolution.Generic"); + return m; +} + +void OneSevenLiveMultiRtmpStreamItem::updateButtonStates() { + if (!m_startStopButton) { + return; + } + + bool canStart = (m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::STOPPED || + m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::ERROR_STATE); + bool canStop = (m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::STREAMING || + m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::CONNECTING || + m_status.state == OneSevenLiveMultiRtmpStreamStatus::State::RECONNECTING); + + if (canStart) { + m_startStopButton->setIcon(QIcon(":/resources/play.svg")); + m_startStopButton->setToolTip(obs_module_text("MultiRTMP.Start")); + m_startStopButton->setEnabled(true); + } else if (canStop) { + m_startStopButton->setIcon(QIcon(":/resources/stop.svg")); + m_startStopButton->setToolTip(obs_module_text("MultiRTMP.Stop")); + m_startStopButton->setEnabled(true); + } else { + m_startStopButton->setIcon(QIcon(":/resources/play.svg")); + m_startStopButton->setToolTip(obs_module_text("MultiRTMP.Wait")); + m_startStopButton->setEnabled(false); + } + + // Edit and delete buttons are disabled when streaming + if (m_editButton) { + m_editButton->setEnabled(!isActive() && !isConnecting()); + } + if (m_menuButton) { + m_menuButton->setEnabled(!isActive() && !isConnecting()); + } +} + +QString OneSevenLiveMultiRtmpStreamItem::formatDuration(uint64_t seconds) const { + uint64_t hours = seconds / 3600; + uint64_t minutes = (seconds % 3600) / 60; + uint64_t secs = seconds % 60; + + return QString("%1:%2:%3") + .arg(hours, 2, 10, QChar('0')) + .arg(minutes, 2, 10, QChar('0')) + .arg(secs, 2, 10, QChar('0')); +} + +QString OneSevenLiveMultiRtmpStreamItem::formatFrameRate(double fps) const { + if (fps <= 0.0) { + return "0"; + } + + return QString("%1").arg(static_cast(fps)); +} + +void OneSevenLiveMultiRtmpStreamItem::updateStatusDot() { + if (!m_statusDot) { + return; + } + + QString color = getStatusColor(); + m_statusDot->setStyleSheet(QString("background-color: %1; border-radius: 7px;").arg(color)); +} + +QString OneSevenLiveMultiRtmpStreamItem::getStatusText() const { + switch (m_status.state) { + case OneSevenLiveMultiRtmpStreamStatus::State::STOPPED: + return obs_module_text("MultiRTMP.Status.Disconnected"); + + case OneSevenLiveMultiRtmpStreamStatus::State::CONNECTING: + return obs_module_text("MultiRTMP.Status.Connecting"); + + case OneSevenLiveMultiRtmpStreamStatus::State::STREAMING: + return obs_module_text("MultiRTMP.Status.Connected"); + + case OneSevenLiveMultiRtmpStreamStatus::State::RECONNECTING: + return obs_module_text("MultiRTMP.Status.Connecting"); + + case OneSevenLiveMultiRtmpStreamStatus::State::ERROR_STATE: + return obs_module_text("MultiRTMP.Status.Disconnected"); + + default: + return obs_module_text("MultiRTMP.Status.Disconnected"); + } +} + +QString OneSevenLiveMultiRtmpStreamItem::getStatusColor() const { + switch (m_status.state) { + case OneSevenLiveMultiRtmpStreamStatus::State::STOPPED: + return "#A1A9B6"; // Disconnected - gray + + case OneSevenLiveMultiRtmpStreamStatus::State::CONNECTING: + return "#FF873D"; // Connecting - orange + + case OneSevenLiveMultiRtmpStreamStatus::State::STREAMING: + return "#00D22E"; // Connected - green + + case OneSevenLiveMultiRtmpStreamStatus::State::RECONNECTING: + return "#FF873D"; // Reconnecting - orange + + case OneSevenLiveMultiRtmpStreamStatus::State::ERROR_STATE: + return "#A1A9B6"; // Error - gray + + default: + return "#A1A9B6"; // Default - gray + } +} + +QString OneSevenLiveMultiRtmpStreamItem::formatBitrate(uint64_t bytes) const { + if (bytes == 0) { + return "0 Kbps"; + } + + static const char* units[] = {"bps", "Kbps", "Mbps", "Gbps"}; + int unitIndex = static_cast(log10(bytes) / 3); + if (unitIndex >= 4) + unitIndex = 3; + + double value = bytes / pow(1000, unitIndex); + return QString("%1 %2").arg(value, 0, 'f', 1).arg(units[unitIndex]); +} + +void OneSevenLiveMultiRtmpStreamItem::collectRealTimeStats() { + if (!m_manager || !isActive()) { + // Reset stats when not active to prevent stale data + m_stats.currentBitrate = 0.0; + m_stats.currentFPS = 0; + return; + } + + // Get obs_output_t* from manager + obs_output_t* output = m_manager->getStreamOutput(m_config.id); + if (!output) { + // Reset stats when output is not available + m_stats.currentBitrate = 0.0; + m_stats.currentFPS = 0; + return; + } + + using namespace std::chrono; + + auto now = steady_clock::now(); + auto newBytes = obs_output_get_total_bytes(output); + auto newFrames = obs_output_get_total_frames(output); + + // Validate OBS data - ensure we have valid values + if (newBytes == 0 && newFrames == 0) { + // OBS might not have started collecting stats yet, keep previous values + return; + } + + // Calculate time interval with minimum threshold to avoid division by very small numbers + auto interval = duration_cast>(now - m_lastStatsTime).count(); + const double MIN_INTERVAL = 0.1; // Minimum 100ms interval + + if (interval >= MIN_INTERVAL && m_lastStatsTime != m_startTime) { + // Calculate duration since start + m_stats.duration = duration_cast(now - m_startTime); + + // Calculate bitrate with validation + if (newBytes >= m_lastTotalBytes) { // Use >= to handle equal case + auto byteDiff = newBytes - m_lastTotalBytes; + if (byteDiff > 0) { + double newBitrate = (byteDiff * 8.0) / (interval * 1000.0); // Convert to Kbps + + // Apply reasonable bounds (0 to 100 Mbps) + if (newBitrate >= 0.0 && newBitrate <= 100000.0) { + // Apply simple smoothing to reduce flickering + const double SMOOTHING_FACTOR = 0.3; + if (m_stats.currentBitrate > 0.0) { + m_stats.currentBitrate = m_stats.currentBitrate * (1.0 - SMOOTHING_FACTOR) + + newBitrate * SMOOTHING_FACTOR; + } else { + m_stats.currentBitrate = newBitrate; + } + } + } + } else { + // Handle case where bytes decreased (shouldn't happen normally) + // This might indicate a stream restart, reset tracking + m_lastTotalBytes = newBytes; + m_lastTotalFrames = newFrames; + m_lastStatsTime = now; + return; + } + + // Calculate frame rate with validation + if (newFrames >= static_cast(m_lastTotalFrames)) { // Use >= to handle equal case + auto frameDiff = newFrames - m_lastTotalFrames; + if (frameDiff > 0) { + double newFPS = static_cast(frameDiff) / interval; + + // Apply reasonable bounds (0 to 120 FPS) + if (newFPS >= 0.0 && newFPS <= 120.0) { + // Apply simple smoothing to reduce flickering + const double SMOOTHING_FACTOR = 0.3; + if (m_stats.currentFPS > 0) { + double smoothedFPS = m_stats.currentFPS * (1.0 - SMOOTHING_FACTOR) + + newFPS * SMOOTHING_FACTOR; + m_stats.currentFPS = static_cast(std::round(smoothedFPS)); + } else { + m_stats.currentFPS = static_cast(std::round(newFPS)); + } + } + } + } else { + // Handle case where frames decreased (shouldn't happen normally) + // This might indicate a stream restart, reset tracking + m_lastTotalBytes = newBytes; + m_lastTotalFrames = newFrames; + m_lastStatsTime = now; + return; + } + + // Update total stats with validation + m_stats.totalFrames = newFrames; + + // Get dropped frames with validation + uint32_t droppedFrames = obs_output_get_frames_dropped(output); + if (droppedFrames <= static_cast(newFrames)) { // Sanity check + m_stats.droppedFrames = droppedFrames; + } + } + + // Update tracking variables only if we have valid data + if (newBytes >= m_lastTotalBytes && newFrames >= static_cast(m_lastTotalFrames)) { + m_lastTotalBytes = newBytes; + m_lastTotalFrames = newFrames; + m_lastStatsTime = now; + } +} + +#include "moc_OneSevenLiveMultiRtmpStreamItem.cpp" diff --git a/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.hpp b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.hpp new file mode 100644 index 0000000..7bff06c --- /dev/null +++ b/src/17live/multi-rtmp/ui/OneSevenLiveMultiRtmpStreamItem.hpp @@ -0,0 +1,161 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveMultiRtmpModels.hpp" +#include "plugin-support.h" + +// Forward declaration +class OneSevenLiveMultiRtmpManager; + +/** + * Individual stream item widget + * Displays stream configuration, status, and controls for a single RTMP stream + */ +class OneSevenLiveMultiRtmpStreamItem : public QFrame { + Q_OBJECT + + public: + explicit OneSevenLiveMultiRtmpStreamItem(const OneSevenLiveMultiRtmpConfig& config, + QWidget* parent = nullptr); + ~OneSevenLiveMultiRtmpStreamItem(); + + // Configuration management + void updateConfig(const OneSevenLiveMultiRtmpConfig& config); + void updateStatus(const OneSevenLiveMultiRtmpStreamStatus& status); + void updateStats(const OneSevenLiveMultiRtmpStreamStats& stats); + + // Manager access + void setManager(OneSevenLiveMultiRtmpManager* manager); + + // Getters + const std::string& getStreamId() const { + return m_config.id; + } + + const OneSevenLiveMultiRtmpConfig& getConfig() const { + return m_config; + } + + const OneSevenLiveMultiRtmpStreamStatus& getStatus() const { + return m_status; + } + + const OneSevenLiveMultiRtmpStreamStats& getStats() const { + return m_stats; + } + + // State queries + bool isActive() const; + bool isConnecting() const; + bool isError() const; + + signals: + void startRequested(const std::string& streamId); + void stopRequested(const std::string& streamId); + void editRequested(const std::string& streamId); + void deleteRequested(const std::string& streamId); + + private slots: + void onStartStopClicked(); + void onEditClicked(); + void onMenuRequested(); + void onDeleteAction(); + void onStatsUpdateTimer(); + + private: + void setupUI(); + void setupContextMenu(); + void updateUI(); + void updateStatusDisplay(); + void updateStatsDisplay(); + void updateButtonStates(); + void setStatusStyle(const QString& className); + void updateStatusDot(); + QString getStatusText() const; + QString getStatusColor() const; + QString formatBitrate(uint64_t bytes) const; + QString formatDuration(uint64_t seconds) const; + QString formatFrameRate(double fps) const; + + // Real-time statistics collection + void collectRealTimeStats(); + + // Configuration and state + OneSevenLiveMultiRtmpConfig m_config; + OneSevenLiveMultiRtmpStreamStatus m_status; + OneSevenLiveMultiRtmpStreamStats m_stats; + + // UI components - Main layout (3-layer vertical) + QVBoxLayout* m_mainLayout = nullptr; + + // Top layer - Name and status + QHBoxLayout* m_topLayout = nullptr; + QLabel* m_nameLabel = nullptr; + QHBoxLayout* m_statusLayout = nullptr; + QLabel* m_statusDot = nullptr; + QLabel* m_statusLabel = nullptr; + + // Middle layer - Statistics + QVBoxLayout* m_statsLayout = nullptr; + QLabel* m_durationLabel = nullptr; + QLabel* m_bitrateLabel = nullptr; + QLabel* m_framesLabel = nullptr; + + // Bottom layer - Controls + QHBoxLayout* m_controlLayout = nullptr; + QPushButton* m_startStopButton = nullptr; + QPushButton* m_editButton = nullptr; + QPushButton* m_menuButton = nullptr; + + // Context menu + QMenu* m_contextMenu = nullptr; + QAction* m_duplicateAction = nullptr; + QAction* m_deleteAction = nullptr; + + // Update timer + QTimer* m_statsTimer = nullptr; + + // Manager reference for real-time stats + OneSevenLiveMultiRtmpManager* m_manager = nullptr; + + // Real-time statistics tracking + std::chrono::steady_clock::time_point m_startTime; + std::chrono::steady_clock::time_point m_lastStatsTime; + uint64_t m_lastTotalBytes = 0; + uint64_t m_lastTotalFrames = 0; + + // Style classes for different states + static const QString STATUS_IDLE_CLASS; + static const QString STATUS_CONNECTING_CLASS; + static const QString STATUS_ACTIVE_CLASS; + static const QString STATUS_ERROR_CLASS; + static const QString STATUS_STOPPING_CLASS; + QHBoxLayout* m_errorHintLayout = nullptr; + QLabel* m_errorIconLabel = nullptr; + QLabel* m_errorTextLabel = nullptr; + + void updateErrorHint(); + QString composeErrorTooltip(const QString& brief, const QString& detail, + const QString& solution) const; + + struct ErrorMapping { + QString titleKey; + QString descKey; + QString solutionKey; + }; + + ErrorMapping mapErrorCode(const QString& code) const; +}; diff --git a/src/17live/preview/OneSevenLivePreviewConfigLoader.cpp b/src/17live/preview/OneSevenLivePreviewConfigLoader.cpp new file mode 100644 index 0000000..ba5e464 --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewConfigLoader.cpp @@ -0,0 +1,67 @@ +#include "OneSevenLivePreviewConfigLoader.hpp" + +#include + +#include +#include +#include + +#include "../../plugin-support.h" +#include "moc_OneSevenLivePreviewConfigLoader.cpp" + +OneSevenLivePreviewConfigLoader::OneSevenLivePreviewConfigLoader(QObject* parent) + : QObject(parent) {} + +OneSevenLivePreviewConfigLoader::~OneSevenLivePreviewConfigLoader() {} + +bool OneSevenLivePreviewConfigLoader::loadConfiguration(const QString& configPath) { + QFile configFile(configPath); + if (!configFile.open(QIODevice::ReadOnly)) { + obs_log(LOG_WARNING, "Failed to open preview config file: %s", + configPath.toUtf8().constData()); + return false; + } + + QByteArray configData = configFile.readAll(); + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(configData, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + obs_log(LOG_ERROR, "Failed to parse preview config JSON: %s", + parseError.errorString().toUtf8().constData()); + return false; + } + + return parseJsonConfig(jsonDoc.object()); +} + +bool OneSevenLivePreviewConfigLoader::parseJsonConfig(const QJsonObject& jsonObj) { + config = PreviewConfig(); // Reset config + + if (!jsonObj.contains("browser_source")) { + obs_log(LOG_ERROR, "Preview config missing browser_source section"); + return false; + } + + config.sourceType = "browser_source"; + QJsonObject browserObj = jsonObj["browser_source"].toObject(); + + config.url = browserObj["url"].toString(); + config.style = browserObj["css"].toString(); + config.width = browserObj["width"].toInt(640); + config.height = browserObj["height"].toInt(480); + config.fps = browserObj["fps"].toInt(30); + config.isValid = true; + + obs_log(LOG_INFO, "Preview config loaded successfully"); + return true; +} + +OneSevenLivePreviewConfigLoader::PreviewConfig OneSevenLivePreviewConfigLoader::getConfiguration() + const { + return config; +} + +bool OneSevenLivePreviewConfigLoader::isConfigurationValid() const { + return config.isValid; +} diff --git a/src/17live/preview/OneSevenLivePreviewConfigLoader.hpp b/src/17live/preview/OneSevenLivePreviewConfigLoader.hpp new file mode 100644 index 0000000..e136b0a --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewConfigLoader.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +class OneSevenLivePreviewConfigLoader : public QObject { + Q_OBJECT + + public: + struct PreviewConfig { + QString sourceType; + QString url; + QString style; + int width = 1920; + int height = 1080; + int fps = 30; + bool isValid = false; + }; + + explicit OneSevenLivePreviewConfigLoader(QObject* parent = nullptr); + ~OneSevenLivePreviewConfigLoader(); + + bool loadConfiguration(const QString& configPath); + PreviewConfig getConfiguration() const; + bool isConfigurationValid() const; + + private: + PreviewConfig config; + bool parseJsonConfig(const QJsonObject& jsonObj); +}; diff --git a/src/17live/preview/OneSevenLivePreviewDock.cpp b/src/17live/preview/OneSevenLivePreviewDock.cpp new file mode 100644 index 0000000..89ebf3e --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewDock.cpp @@ -0,0 +1,200 @@ +#include "OneSevenLivePreviewDock.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "../../plugin-support.h" +#include "../streaming/OneSevenLiveStreamManager.hpp" +#include "moc_OneSevenLivePreviewDock.cpp" + +OneSevenLivePreviewDock::OneSevenLivePreviewDock(QWidget* parent, const QString& overlayUrl) + : QDockWidget(obs_module_text("PreviewDock.Title"), parent), + overlayUrl_(overlayUrl), + previewWidget(nullptr), + initialized(false) { + setupUi(); +} + +OneSevenLivePreviewDock::~OneSevenLivePreviewDock() { + if (previewWidget) { + previewWidget->deleteLater(); + } +} + +void OneSevenLivePreviewDock::setupUi() { + setObjectName("OneSevenLivePreviewDock"); + setAllowedAreas(Qt::AllDockWidgetAreas); + setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + + // Set minimum size for vertical preview (160x284 minimum) + setMinimumSize(180, 320); + resize(400, 720); + + container = new QWidget(this); + QVBoxLayout* layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + QHBoxLayout* hintLayout = new QHBoxLayout(); + hintLayout->setContentsMargins(10, 10, 10, 0); + hintLayout->setSpacing(5); + hintLayout->setAlignment(Qt::AlignHCenter); + + QLabel* icon = new QLabel(container); + icon->setFixedSize(20, 20); + icon->setPixmap(QPixmap(":/resources/alert-white.svg") + .scaled(20, 20, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + + notificationLabel = + new QLabel(QString::fromUtf8(obs_module_text("PreviewDock.Tip.AnimationOnly")), container); + notificationLabel->setWordWrap(true); + notificationLabel->setAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + notificationLabel->setStyleSheet("color: white; font-size: 14px;"); + + // Add leading stretch to center contents + hintLayout->addStretch(); + hintLayout->addWidget(icon); + hintLayout->addWidget(notificationLabel); + hintLayout->addStretch(); + + QWidget* hintContainer = new QWidget(container); + hintContainer->setLayout(hintLayout); + + previewContainer = new QWidget(container); + previewContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + layout->addWidget(hintContainer); + layout->addWidget(previewContainer, 1); + + setWidget(container); + + previewWidget = new OneSevenLivePreviewWidget(previewContainer, overlayUrl_); + + if (previewWidget) { + connect(previewWidget, &OneSevenLivePreviewWidget::displayCreated, this, + &OneSevenLivePreviewDock::onDisplayCreated); + } + + // Placeholder label + placeholderLabel = new QLabel(obs_module_text("PreviewDock.Initializing"), previewContainer); + placeholderLabel->setAlignment(Qt::AlignCenter); + placeholderLabel->setStyleSheet("background-color: black; color: white; font-size: 12px;"); + placeholderLabel->show(); + + // Loading overlay + loadingOverlay = new QWidget(container); + loadingOverlay->setStyleSheet("background-color: rgba(0, 0, 0, 180);"); + loadingOverlay->hide(); + + QVBoxLayout* overlayLayout = new QVBoxLayout(loadingOverlay); + overlayLayout->setAlignment(Qt::AlignCenter); + + loadingLabel = new QLabel(obs_module_text("PreviewDock.LoadingGifts"), loadingOverlay); + loadingLabel->setStyleSheet("color: white; font-size: 16px; font-weight: bold;"); + overlayLayout->addWidget(loadingLabel); + + auto& core = OneSevenLiveCoreManager::getInstance(); + connect(&core, &OneSevenLiveCoreManager::giftsLoaded, this, + &OneSevenLivePreviewDock::onGiftsLoaded); + + if (!core.isGiftsLoaded()) { + loadingOverlay->show(); + loadingOverlay->raise(); + } +} + +void OneSevenLivePreviewDock::updatePreviewGeometry() { + if (!previewWidget) + return; + if (!previewContainer) + return; + int cw = previewContainer->width(); + int ch = previewContainer->height(); + if (cw <= 0 || ch <= 0) + return; + + bool isLandscape = true; + auto& core = OneSevenLiveCoreManager::getInstance(); + if (core.getStreamManager()) { + isLandscape = core.getStreamManager()->getRoomInfo().landscape; + } + + double aspect = isLandscape ? (16.0 / 9.0) : (640.0 / 1136.0); + int targetW = cw; + int targetH = static_cast(std::round(targetW / aspect)); + if (targetH > ch) { + targetH = ch; + targetW = cw; // keep width full per requirement + } + int x = (cw - targetW) / 2; + int y = (ch - targetH) / 2; + previewWidget->setGeometry(x, y, targetW, targetH); + if (placeholderLabel) { + placeholderLabel->setGeometry(x, y, targetW, targetH); + } +} + +void OneSevenLivePreviewDock::initializePreview() { + if (!initialized && previewWidget) { + initialized = true; + } +} + +void OneSevenLivePreviewDock::showEvent(QShowEvent* event) { + QDockWidget::showEvent(event); + + if (!initialized) { + initializePreview(); + } + + updatePreviewGeometry(); + + if (loadingOverlay && loadingOverlay->isVisible() && container) { + loadingOverlay->resize(container->size()); + loadingOverlay->raise(); + } + + if (previewWidget) { + QTimer::singleShot(0, previewWidget, &OneSevenLivePreviewWidget::syncDisplaySize); + QTimer::singleShot(0, previewWidget, &OneSevenLivePreviewWidget::forceRefresh); + } +} + +void OneSevenLivePreviewDock::resizeEvent(QResizeEvent* event) { + QDockWidget::resizeEvent(event); + updatePreviewGeometry(); + + if (loadingOverlay && loadingOverlay->isVisible() && container) { + loadingOverlay->resize(container->size()); + loadingOverlay->raise(); + } + + if (previewWidget) { + QTimer::singleShot(0, previewWidget, &OneSevenLivePreviewWidget::syncDisplaySize); + QTimer::singleShot(0, previewWidget, &OneSevenLivePreviewWidget::forceRefresh); + } +} + +void OneSevenLivePreviewDock::closeEvent(QCloseEvent* event) { + emit dockClosed(); + QDockWidget::closeEvent(event); +} + +void OneSevenLivePreviewDock::onGiftsLoaded() { + if (loadingOverlay) { + loadingOverlay->hide(); + } +} + +void OneSevenLivePreviewDock::onDisplayCreated(bool created) { + if (placeholderLabel) { + placeholderLabel->setVisible(!created); + } +} diff --git a/src/17live/preview/OneSevenLivePreviewDock.hpp b/src/17live/preview/OneSevenLivePreviewDock.hpp new file mode 100644 index 0000000..9540e0e --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewDock.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../OneSevenLiveCoreManager.hpp" +#include "OneSevenLivePreviewWidget.hpp" + +class OneSevenLivePreviewDock : public QDockWidget { + Q_OBJECT + + public: + explicit OneSevenLivePreviewDock(QWidget* parent = nullptr, + const QString& overlayUrl = QString()); + ~OneSevenLivePreviewDock(); + + void initializePreview(); + + protected: + void showEvent(QShowEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void closeEvent(QCloseEvent* event) override; + + signals: + void dockClosed(); + + private: + void setupUi(); + void updatePreviewGeometry(); + + QPointer previewWidget = nullptr; + QWidget* container = nullptr; + QWidget* previewContainer = nullptr; + QLabel* notificationLabel = nullptr; + QWidget* loadingOverlay = nullptr; + QLabel* loadingLabel = nullptr; + QLabel* placeholderLabel = nullptr; + + bool initialized = false; + QString overlayUrl_; + + private slots: + void onGiftsLoaded(); + void onDisplayCreated(bool created); +}; diff --git a/src/17live/preview/OneSevenLivePreviewWidget.cpp b/src/17live/preview/OneSevenLivePreviewWidget.cpp new file mode 100644 index 0000000..dcad3f7 --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewWidget.cpp @@ -0,0 +1,520 @@ +#include "OneSevenLivePreviewWidget.hpp" + +#include + +#include "../../plugin-support.h" +#ifdef __APPLE__ +#include +#endif +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "moc_OneSevenLivePreviewWidget.cpp" +#include "utility/Common.hpp" + +OneSevenLivePreviewWidget::OneSevenLivePreviewWidget(QWidget* parent, const QString& overlayUrl) + : QWidget(parent), + previewDisplay(nullptr), + display_created(false), + currentSource(nullptr), + refreshTimer(new QTimer(this)), + display_width(0), + display_height(0), + browserSource(nullptr), + configLoader(new OneSevenLivePreviewConfigLoader(this)), + browserRefreshTimer(new QTimer(this)), + overlayScale(1.0f), + overlayUrl_(overlayUrl) { + // Set widget attributes for proper native rendering + setAttribute(Qt::WA_NativeWindow, true); + setAttribute(Qt::WA_PaintOnScreen, true); + setAttribute(Qt::WA_OpaquePaintEvent, true); + setAttribute(Qt::WA_NoSystemBackground, true); + + // Set background only; allow full responsive sizing + setAutoFillBackground(false); + + QPalette palette = this->palette(); + palette.setColor(QPalette::Window, Qt::black); + setPalette(palette); + +#ifdef _WIN32 + QFont safeFont; + safeFont.setFamily("Segoe UI"); + safeFont.setPointSize(10); + setFont(safeFont); +#endif + + // Set up refresh timer (30 FPS) + refreshTimer->setInterval(33); + connect(refreshTimer, &QTimer::timeout, this, &OneSevenLivePreviewWidget::refreshVideo); + refreshTimer->start(); + + // Connect to OBS frontend events + obs_frontend_add_event_callback(frontendEvent, this); + + // Load browser source configuration and create browser source + loadBrowserSourceConfig(); + createBrowserSource(); + + // Set up browser refresh timer + browserRefreshTimer->setInterval(1000); // Refresh every second + connect(browserRefreshTimer, &QTimer::timeout, this, + &OneSevenLivePreviewWidget::updateBrowserSource); + browserRefreshTimer->start(); +} + +OneSevenLivePreviewWidget::~OneSevenLivePreviewWidget() { + // Disconnect all signals to prevent calling slots on destroyed objects + disconnect(this, nullptr, nullptr, nullptr); + + if (refreshTimer) { + refreshTimer->stop(); + } + if (browserRefreshTimer) { + browserRefreshTimer->stop(); + } + obs_frontend_remove_event_callback(frontendEvent, this); + destroyBrowserSource(); + destroyDisplay(); +} + +void OneSevenLivePreviewWidget::createDisplay() { + if (display_created || !isVisible()) { + return; + } + + // Get the native window handle + WId windowId = winId(); + if (windowId == 0) { + return; + } + + // Calculate display dimensions with device pixel ratio + QScreen* screen = QGuiApplication::primaryScreen(); + qreal dpr = screen ? screen->devicePixelRatio() : 1.0; + + int logical_width = width(); + int logical_height = height(); + int physical_width = static_cast(logical_width * dpr); + int physical_height = static_cast(logical_height * dpr); + + // Create OBS display + gs_init_data init_data = {}; + init_data.cx = physical_width; + init_data.cy = physical_height; + init_data.format = GS_BGRA; + init_data.zsformat = GS_ZS_NONE; + +#ifdef __APPLE__ + init_data.window.view = (id) windowId; +#elif defined(_WIN32) + init_data.window.hwnd = reinterpret_cast(windowId); +#else + init_data.window.id = windowId; +#endif + + previewDisplay = obs_display_create(&init_data, 0x0); + + if (previewDisplay) { + display_created = true; + display_width = physical_width; + display_height = physical_height; + + obs_display_add_draw_callback(previewDisplay, drawCallback, this); + + emit displayCreated(true); + } +} + +void OneSevenLivePreviewWidget::destroyDisplay() { + if (previewDisplay) { + obs_display_remove_draw_callback(previewDisplay, drawCallback, this); + obs_display_destroy(previewDisplay); + previewDisplay = nullptr; + } + display_created = false; + + emit displayCreated(false); + + if (currentSource) { + obs_source_release(currentSource); + currentSource = nullptr; + } +} + +void OneSevenLivePreviewWidget::drawCallback(void* data, uint32_t cx, uint32_t cy) { + auto* widget = static_cast(data); + if (!widget) { + return; + } + + widget->renderScene(cx, cy); +} + +void OneSevenLivePreviewWidget::renderScene(uint32_t cx, uint32_t cy) { + // Set up viewport and projection + gs_viewport_push(); + gs_projection_push(); + + gs_set_viewport(0, 0, cx, cy); + gs_ortho(0.0f, (float) cx, 0.0f, (float) cy, -100.0f, 100.0f); + + // Clear background + vec4 clear_color; + vec4_set(&clear_color, 0.0f, 0.0f, 0.0f, 1.0f); + gs_clear(GS_CLEAR_COLOR, &clear_color, 0.0f, 0); + + // Render main source + if (currentSource) { + uint32_t source_width = obs_source_get_width(currentSource); + uint32_t source_height = obs_source_get_height(currentSource); + + if (source_width > 0 && source_height > 0) { + // Calculate scaling to fit while maintaining aspect ratio (ensure entire video is + // visible) + float scale_x = (float) cx / (float) source_width; + float scale_y = (float) cy / (float) source_height; + // Use the smaller scale to ensure entire video content is visible within preview bounds + float scale = std::min(scale_x, scale_y); + + // Center the source + float scaled_width = (float) source_width * scale; + float scaled_height = (float) source_height * scale; + float offset_x = ((float) cx - scaled_width) * 0.5f; + float offset_y = ((float) cy - scaled_height) * 0.5f; + + // Apply transformation and render + gs_matrix_push(); + gs_matrix_translate3f(offset_x, offset_y, 0.0f); + gs_matrix_scale3f(scale, scale, 1.0f); + + obs_source_video_render(currentSource); + + gs_matrix_pop(); + } + } + + // Render browser source overlay + if (browserSource) { + // Get fresh reference to ensure source is still valid + obs_source_t* source_ref = obs_source_get_ref(browserSource); + if (source_ref) { + const char* source_name = obs_source_get_name(source_ref); + if (!source_name || strlen(source_name) == 0) { + obs_source_release(source_ref); + return; + } + + uint32_t browser_width = obs_source_get_width(source_ref); + uint32_t browser_height = obs_source_get_height(source_ref); + bool is_active = obs_source_active(source_ref); + bool is_showing = obs_source_showing(source_ref); + + if (browser_width > 0 && browser_height > 0 && is_active && is_showing) { + // Apply overlay transformation to cover entire preview area + gs_matrix_push(); + + // Calculate scale to fill the entire preview area + float preview_width = static_cast(cx); + float preview_height = static_cast(cy); + float browser_width_f = static_cast(browser_width); + float browser_height_f = static_cast(browser_height); + + // Calculate scale factors for both dimensions + float scale_x = preview_width / browser_width_f; + float scale_y = preview_height / browser_height_f; + + // Use the larger scale to ensure overlay covers entire area + float fill_scale = qMax(scale_x, scale_y); + + // Apply the overlay scale factor from OneSevenLivePreviewScreen + float final_scale = fill_scale * overlayScale; + + // Calculate position to center the scaled overlay + float scaled_browser_width = browser_width_f * final_scale; + float scaled_browser_height = browser_height_f * final_scale; + float overlay_x = (preview_width - scaled_browser_width) * 0.5f; + float overlay_y = (preview_height - scaled_browser_height) * 0.5f; + + gs_matrix_translate3f(overlay_x, overlay_y, 0.0f); + gs_matrix_scale3f(final_scale, final_scale, 1.0f); + + obs_source_video_render(source_ref); + + gs_matrix_pop(); + } + + obs_source_release(source_ref); + } + } + + // Restore graphics state + gs_projection_pop(); + gs_viewport_pop(); +} + +void OneSevenLivePreviewWidget::refreshVideo() { + // Update current program source + obs_source_t* newSource = getCurrentProgramSource(); + + if (currentSource != newSource) { + if (currentSource) { + obs_source_release(currentSource); + } + currentSource = newSource; + if (currentSource) { + obs_source_get_ref(currentSource); + } + } + + // Release temporary reference + if (newSource) { + obs_source_release(newSource); + } + + // Force display refresh + if (previewDisplay && display_created) { + obs_display_set_enabled(previewDisplay, true); + } +} + +obs_source_t* OneSevenLivePreviewWidget::getCurrentProgramSource() { + return obs_frontend_get_current_scene(); +} + +void OneSevenLivePreviewWidget::updateVideoInfo() { + if (previewDisplay) { + display_width = width(); + display_height = height(); + + // Get device pixel ratio for HiDPI support + qreal device_pixel_ratio = 1.0; + QWindow* window_handle = windowHandle(); + if (!window_handle) { + window_handle = window()->windowHandle(); + } + if (window_handle) { + device_pixel_ratio = window_handle->devicePixelRatio(); + } else { + QScreen* screen = QGuiApplication::primaryScreen(); + if (screen) { + device_pixel_ratio = screen->devicePixelRatio(); + } + } + + // Calculate physical dimensions for HiDPI + int phys_cx = static_cast(std::lround(static_cast(display_width) * + static_cast(device_pixel_ratio))); + int phys_cy = static_cast(std::lround(static_cast(display_height) * + static_cast(device_pixel_ratio))); + + // obs_log(LOG_INFO, "Resizing display: logical=%dx%d, physical=%dx%d, dpr=%.2f", + // display_width, display_height, phys_cx, phys_cy, device_pixel_ratio); + + obs_display_resize(previewDisplay, phys_cx, phys_cy); + } +} + +void OneSevenLivePreviewWidget::resizeEvent(QResizeEvent* event) { + QWidget::resizeEvent(event); + + if (display_created && previewDisplay) { + QScreen* screen = QGuiApplication::primaryScreen(); + qreal dpr = screen ? screen->devicePixelRatio() : 1.0; + + int logical_width = event->size().width(); + int logical_height = event->size().height(); + int physical_width = static_cast(logical_width * dpr); + int physical_height = static_cast(logical_height * dpr); + + display_width = physical_width; + display_height = physical_height; + + obs_display_resize(previewDisplay, physical_width, physical_height); + + // Force refresh to ensure content scales properly with new size + forceRefresh(); + } +} + +void OneSevenLivePreviewWidget::showEvent(QShowEvent* event) { + QWidget::showEvent(event); + QTimer::singleShot(0, this, &OneSevenLivePreviewWidget::createDisplay); +} + +void OneSevenLivePreviewWidget::hideEvent(QHideEvent* event) { + QWidget::hideEvent(event); + destroyDisplay(); +} + +void OneSevenLivePreviewWidget::paintEvent(QPaintEvent* event) { + Q_UNUSED(event); +} + +QPaintEngine* OneSevenLivePreviewWidget::paintEngine() const { + return nullptr; +} + +void OneSevenLivePreviewWidget::frontendEvent(enum obs_frontend_event event, void* data) { + auto* widget = static_cast(data); + if (!widget) { + return; + } + + if (event == OBS_FRONTEND_EVENT_SCENE_CHANGED) { + QMetaObject::invokeMethod(widget, "refreshVideo", Qt::QueuedConnection); + } +} + +void OneSevenLivePreviewWidget::loadBrowserSourceConfig() { + // Get plugin data directory + std::string dataPath = get_obs_module_data_path_str(); + QString configPath = QString("%1/preview_config.json").arg(QString::fromStdString(dataPath)); + + if (configLoader->loadConfiguration(configPath)) { + browserConfig = configLoader->getConfiguration(); + } else { + obs_log(LOG_WARNING, "Failed to load browser source config, using defaults"); + browserConfig.isValid = false; + } +} + +void OneSevenLivePreviewWidget::createBrowserSource() { + if (browserSource) { + destroyBrowserSource(); + } + + // Check if browser source plugin is available + const char* source_id = "browser_source"; + if (!obs_source_get_display_name(source_id)) { + obs_log(LOG_ERROR, "Browser source plugin not available! Source ID '%s' not found", + source_id); + return; + } + + // Only create browser source if we have valid configuration or an overlay URL + if (!browserConfig.isValid && overlayUrl_.isEmpty()) { + obs_log(LOG_INFO, + "No valid browser source configuration and no overlay URL, skipping browser source " + "creation"); + return; + } + + // Create settings from configuration (allow override by overlayUrl_) + ObsDataPtr settings{obs_data_create()}; + + QString effectiveUrl; + if (!overlayUrl_.isEmpty()) { + effectiveUrl = overlayUrl_; + } else if (browserConfig.isValid) { + effectiveUrl = browserConfig.url; + } else { + effectiveUrl = "about:blank"; // Should not happen given check above + } + + // obs_log(LOG_INFO, "Using overlay URL: %s", effectiveUrl.toUtf8().constData()); + obs_data_set_string(settings.get(), "url", effectiveUrl.toUtf8().constData()); + obs_data_set_int(settings.get(), "width", browserConfig.isValid ? browserConfig.width : 1920); + obs_data_set_int(settings.get(), "height", browserConfig.isValid ? browserConfig.height : 1080); + obs_data_set_int(settings.get(), "fps", browserConfig.isValid ? browserConfig.fps : 30); + obs_data_set_bool(settings.get(), "shutdown", false); + obs_data_set_bool(settings.get(), "restart_when_active", false); + obs_data_set_bool(settings.get(), "reroute_audio", false); + + QString uniquePath = QDir::homePath() + "/.17Live/obs_browser_storage_preview"; + QDir().mkpath(uniquePath); + obs_data_set_string(settings.get(), "local_storage_path", uniquePath.toStdString().c_str()); + + // Create browser source + browserSource = + obs_source_create("browser_source", "LivePreviewOverlay", settings.get(), nullptr); + + if (browserSource) { + // Get reference and activate source + obs_source_t* source_ref = obs_source_get_ref(browserSource); + if (source_ref) { + obs_source_inc_showing(source_ref); + obs_source_inc_active(source_ref); + obs_source_release(source_ref); + } + obs_log(LOG_INFO, "Preview Cartoon Browser source created successfully"); + } else { + obs_log(LOG_ERROR, "Failed to create browser source"); + } + + settings.reset(); +} + +void OneSevenLivePreviewWidget::destroyBrowserSource() { + if (browserSource) { + // Get reference and properly deactivate + obs_source_t* source_ref = obs_source_get_ref(browserSource); + if (source_ref) { + obs_source_dec_showing(source_ref); + obs_source_dec_active(source_ref); + obs_source_release(source_ref); + } + + obs_source_release(browserSource); + browserSource = nullptr; + } +} + +void OneSevenLivePreviewWidget::updateBrowserSource() { + if (!browserSource) { + return; + } + + // Force browser source to refresh by triggering a property update + ObsDataPtr settings{obs_source_get_settings(browserSource)}; + if (settings) { + // Update the URL to trigger a refresh; overlayUrl_ overrides config + const QString effectiveUrl = overlayUrl_.isEmpty() ? browserConfig.url : overlayUrl_; + obs_data_set_string(settings.get(), "url", effectiveUrl.toUtf8().constData()); + obs_source_update(browserSource, settings.get()); + settings.reset(); + } +} + +void OneSevenLivePreviewWidget::setOverlayScale(float scale) { + overlayScale = qMax(0.1f, qMin(5.0f, scale)); // Clamp between 0.1 and 5.0 + + // Force refresh to apply new scale + forceRefresh(); +} + +void OneSevenLivePreviewWidget::setOverlayUrl(const QString& url) { + overlayUrl_ = url; + // Apply immediately if browser source exists + updateBrowserSource(); + obs_log(LOG_INFO, "Preview overlay URL %s", + overlayUrl_.isEmpty() ? "(using config)" : overlayUrl_.toUtf8().constData()); +} + +void OneSevenLivePreviewWidget::forceRefresh() { + if (previewDisplay && display_created) { + // Invalidate the display to force re-rendering + obs_display_set_enabled(previewDisplay, false); + obs_display_set_enabled(previewDisplay, true); + + // Also trigger a video refresh + refreshVideo(); + } +} + +void OneSevenLivePreviewWidget::syncDisplaySize() { + updateVideoInfo(); +} diff --git a/src/17live/preview/OneSevenLivePreviewWidget.hpp b/src/17live/preview/OneSevenLivePreviewWidget.hpp new file mode 100644 index 0000000..5591aed --- /dev/null +++ b/src/17live/preview/OneSevenLivePreviewWidget.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "OneSevenLivePreviewConfigLoader.hpp" + +class OneSevenLivePreviewWidget : public QWidget { + Q_OBJECT + + public: + explicit OneSevenLivePreviewWidget(QWidget* parent = nullptr, + const QString& overlayUrl = QString()); + ~OneSevenLivePreviewWidget(); + + /** + * @brief Set the overlay scale factor for browser sources + * @param scale Scale factor (1.0 = original size) + */ + void setOverlayScale(float scale); + + /** + * @brief Force refresh the display and overlays + */ + void forceRefresh(); + void syncDisplaySize(); + + /** + * @brief Set an override URL for the browser overlay. + * When set (non-empty), this URL is used instead of config-defined URL. + */ + void setOverlayUrl(const QString& url); + + protected: + void resizeEvent(QResizeEvent* event) override; + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + void paintEvent(QPaintEvent* event) override; + QPaintEngine* paintEngine() const override; + + private slots: + void refreshVideo(); + + signals: + void displayCreated(bool created); + + private: + void createDisplay(); + void destroyDisplay(); + void updateVideoInfo(); + void loadBrowserSourceConfig(); + void createBrowserSource(); + void destroyBrowserSource(); + void updateBrowserSource(); + obs_source_t* getCurrentProgramSource(); + static void drawCallback(void* data, uint32_t cx, uint32_t cy); + void renderScene(uint32_t cx, uint32_t cy); + static void frontendEvent(enum obs_frontend_event event, void* data); + + // Core display components + obs_display_t* previewDisplay; + bool display_created; + + // Video source management + obs_source_t* currentSource; + QTimer* refreshTimer; + + // Display dimensions + int display_width; + int display_height; + + // Browser source overlay components + obs_source_t* browserSource = nullptr; + OneSevenLivePreviewConfigLoader* configLoader = nullptr; + OneSevenLivePreviewConfigLoader::PreviewConfig browserConfig; + QTimer* browserRefreshTimer = nullptr; + + // Overlay scaling + float overlayScale = 1.0f; + + // Optional overlay URL override + QString overlayUrl_; +}; diff --git a/src/17live/OneSevenLiveRockViewerItem.cpp b/src/17live/rockzone/OneSevenLiveRockViewerItem.cpp similarity index 52% rename from src/17live/OneSevenLiveRockViewerItem.cpp rename to src/17live/rockzone/OneSevenLiveRockViewerItem.cpp index 7398afe..2491a4a 100644 --- a/src/17live/OneSevenLiveRockViewerItem.cpp +++ b/src/17live/rockzone/OneSevenLiveRockViewerItem.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include "api/OneSevenLiveApiWrappers.hpp" #include "api/OneSevenLiveUtility.hpp" #include "moc_OneSevenLiveRockViewerItem.cpp" +#include "utility/Common.hpp" #include "utility/RemoteTextThread.hpp" OneSevenLiveRockViewerItem::OneSevenLiveRockViewerItem( @@ -31,7 +33,8 @@ OneSevenLiveRockViewerItem::OneSevenLiveRockViewerItem( } QSize OneSevenLiveRockViewerItem::sizeHint() const { - return QSize(350, 80); + // Return fixed size 280x80 + return QSize(280, 80); } QString OneSevenLiveRockViewerItem::buildUrl(const QString &path) { @@ -43,16 +46,15 @@ QString OneSevenLiveRockViewerItem::buildUrl(const QString &path) { } void OneSevenLiveRockViewerItem::setupUi() { - // Root layout centers a fixed-size inner card to achieve visual width=300 while - // allowing the outer widget to stretch with the QListWidget viewport + // Root layout allows the card to stretch with the QListWidget viewport QHBoxLayout *rootLayout = new QHBoxLayout(this); rootLayout->setContentsMargins(0, 0, 0, 0); rootLayout->setAlignment(Qt::AlignLeft); QWidget *card = new QWidget(this); - card->setFixedSize(300, 80); + card->setFixedSize(280, 80); // Set fixed size to 280x80 QHBoxLayout *mainLayout = new QHBoxLayout(card); - mainLayout->setContentsMargins(0, 0, 0, 0); // item padding ~10 + mainLayout->setContentsMargins(0, 0, 0, 0); // Add horizontal padding mainLayout->setSpacing(5); mainLayout->setAlignment(Qt::AlignLeft); @@ -60,57 +62,51 @@ void OneSevenLiveRockViewerItem::setupUi() { setCursor(Qt::PointingHandCursor); // Setup avatar area - QLabel *avatarLabel = setupAvatar(); + setupAvatar(); mainLayout->addWidget(avatarLabel, 0, Qt::AlignVCenter); // Right side: 3 vertical sections - QVBoxLayout *rightLayout = new QVBoxLayout(); - rightLayout->setContentsMargins(0, 5, 0, 5); + rightLayout = new QVBoxLayout(); + rightLayout->setContentsMargins(0, 0, 0, 0); rightLayout->setSpacing(4); // reduce spacing between components rightLayout->setAlignment(Qt::AlignTop); // Setup name row - QHBoxLayout *nameRow = setupNameRow(); - rightLayout->addLayout(nameRow); + nameRowLayout = setupNameRow(); + rightLayout->addLayout(nameRowLayout); // Setup badge row - QHBoxLayout *badgeRow = setupBadgeRow(); - if (badgeRow) { - rightLayout->addLayout(badgeRow); + badgeRowLayout = setupBadgeRow(); + if (badgeRowLayout) { + rightLayout->addLayout(badgeRowLayout); } - // Add stretch to push all components to the top - // rightLayout->addStretch(); - - // 3) Invested points - // { - // int points = user.armyInfo.pointContribution; // invest points - // QLabel *pointsLabel = new QLabel(QString::number(points), this); - // pointsLabel->setStyleSheet( - // "QLabel {" - // " color: #D9D9D9;" - // " font-size: 12px;" - // "}"); - // pointsLabel->setAlignment(Qt::AlignLeft); - // pointsLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); - // rightLayout->addWidget(pointsLabel, 0, Qt::AlignLeft); - // } + // Setup points row + QHBoxLayout *pointsRow = setupPointsRow(); + rightLayout->addLayout(pointsRow); mainLayout->addLayout(rightLayout, 1); - // Mount card to root centered layout + // Mount card to root layout with fixed size rootLayout->addWidget(card, 0, Qt::AlignLeft); setLayout(rootLayout); } -QLabel *OneSevenLiveRockViewerItem::setupAvatar() { +void OneSevenLiveRockViewerItem::setupAvatar() { // Left: Avatar with overlay frame - QLabel *avatarLabel = new QLabel(this); + avatarLabel = new QLabel(this); avatarLabel->setFixedSize(55, 57); // avatar area 55x57 avatarLabel->setStyleSheet("QLabel { background-color: transparent; }"); // Forward clicks to parent widget so any click inside the item triggers avatarLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + reloadAvatar(); +} + +void OneSevenLiveRockViewerItem::reloadAvatar() { + if (!avatarLabel) + return; + // Keep pixmaps across async loads auto avatarReady = QSharedPointer::create(false); auto frameReady = QSharedPointer::create(false); @@ -223,8 +219,6 @@ QLabel *OneSevenLiveRockViewerItem::setupAvatar() { } } } - - return avatarLabel; } QHBoxLayout *OneSevenLiveRockViewerItem::setupNameRow() { @@ -270,19 +264,20 @@ QHBoxLayout *OneSevenLiveRockViewerItem::setupNameRow() { QHBoxLayout *OneSevenLiveRockViewerItem::setupBadgeRow() { // 2) Badge list // Badge labels based on merged badgeTypes; skip if none or all empty - QHBoxLayout *badgeRow = nullptr; + QHBoxLayout *badgeRow = new QHBoxLayout(); + badgeRow->setContentsMargins(0, 0, 0, 0); + badgeRow->setSpacing(6); + badgeRow->setAlignment(Qt::AlignLeft); + + bool hasBadges = false; for (int t : user.badgeTypes) { const QString labelText = OneSevenLiveUtility::badgeLabel(t, user.armyInfo.rank, &armyNameResponse); if (labelText.isEmpty()) { continue; } - if (!badgeRow) { - badgeRow = new QHBoxLayout(); - badgeRow->setContentsMargins(0, 0, 0, 0); - badgeRow->setSpacing(6); - badgeRow->setAlignment(Qt::AlignLeft); - } + hasBadges = true; + // Build a composite badge: [Gradient text label] + [Right image] QWidget *badge = new QWidget(this); QHBoxLayout *badgeLayout = new QHBoxLayout(badge); @@ -323,6 +318,11 @@ QHBoxLayout *OneSevenLiveRockViewerItem::setupBadgeRow() { badgeRow->addWidget(badge, 0, Qt::AlignLeft); } + if (!hasBadges) { + badgeRow->deleteLater(); + return nullptr; + } + return badgeRow; } @@ -333,13 +333,243 @@ void OneSevenLiveRockViewerItem::mousePressEvent(QMouseEvent *event) { QWidget::mousePressEvent(event); } -void OneSevenLiveRockViewerItem::updateData(const OneSevenLiveRockZoneViewer &user, - const OneSevenLiveArmyNameResponse &armyNameResponse) { - this->user = user; - this->armyNameResponse = armyNameResponse; +QHBoxLayout *OneSevenLiveRockViewerItem::setupPointsRow() { + int points = user.userAttr.sentPoint; // sent points + + // Create horizontal layout for icon and points + QHBoxLayout *pointsLayout = new QHBoxLayout(); + pointsLayout->setContentsMargins(0, 0, 0, 0); + pointsLayout->setSpacing(4); // Small spacing between icon and text + + // Add baobaobi icon + QLabel *iconLabel = new QLabel(this); + iconLabel->setFixedSize(16, 16); + iconLabel->setStyleSheet("QLabel { background-color: transparent; }"); + iconLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + // Load SVG icon + QPixmap iconPixmap(":/resources/baobaobi.svg"); + if (!iconPixmap.isNull()) { + iconLabel->setPixmap( + iconPixmap.scaled(16, 16, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + + // Format points with thousand separators based on current language + QLocale locale; + std::string currentLang = GetCurrentLanguage(); + if (currentLang == "TW") { + locale = QLocale(QLocale::Chinese, QLocale::Taiwan); + } else if (currentLang == "JP") { + locale = QLocale(QLocale::Japanese, QLocale::Japan); + } else { // US + locale = QLocale(QLocale::English, QLocale::UnitedStates); + } + QString formattedPoints = locale.toString(points); + + pointsLabel = new QLabel(formattedPoints, this); + pointsLabel->setStyleSheet( + "QLabel {" + " color: #D9D9D9;" + " font-size: 12px;" + "}"); + pointsLabel->setAlignment(Qt::AlignLeft); + pointsLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + pointsLayout->addWidget(iconLabel); + pointsLayout->addWidget(pointsLabel); + pointsLayout->addStretch(); // Push content to the left + + return pointsLayout; +} + +void OneSevenLiveRockViewerItem::updateData( + const OneSevenLiveRockZoneViewer &newUser, + const OneSevenLiveArmyNameResponse &newArmyNameResponse) { + const auto &oldUser = this->user; + + bool avatarChanged = (newUser.displayUser.picture != oldUser.displayUser.picture) || + (OneSevenLiveUtility::avatarFrameResource(newUser) != + OneSevenLiveUtility::avatarFrameResource(oldUser)) || + (OneSevenLiveUtility::mLevelBadgeResource(newUser) != + OneSevenLiveUtility::mLevelBadgeResource(oldUser)); + + bool nameChanged = (newUser.displayUser.displayName != oldUser.displayUser.displayName) || + (OneSevenLiveUtility::checkingLevelBadgeResource(newUser) != + OneSevenLiveUtility::checkingLevelBadgeResource(oldUser)); + + bool pointsChanged = (newUser.userAttr.sentPoint != oldUser.userAttr.sentPoint); + + bool badgesChanged = (newUser.badgeTypes != oldUser.badgeTypes) || + (newUser.armyInfo.rank != oldUser.armyInfo.rank); + + this->user = newUser; + this->armyNameResponse = newArmyNameResponse; + + if (avatarChanged) { + reloadAvatar(); + } + + if (nameChanged) { + updateNameRow(); + } + + if (pointsChanged) { + QLocale locale; + std::string currentLang = GetCurrentLanguage(); + if (currentLang == "TW") { + locale = QLocale(QLocale::Chinese, QLocale::Taiwan); + } else if (currentLang == "JP") { + locale = QLocale(QLocale::Japanese, QLocale::Japan); + } else { // US + locale = QLocale(QLocale::English, QLocale::UnitedStates); + } + QString formattedPoints = locale.toString(user.userAttr.sentPoint); + if (pointsLabel) + pointsLabel->setText(formattedPoints); + } + + if (badgesChanged) { + updateBadges(); + } + + updateGeometry(); +} + +void OneSevenLiveRockViewerItem::updateNameRow() { + if (!nameRowLayout) + return; + + // Clear existing items + QLayoutItem *child; + while ((child = nameRowLayout->takeAt(0)) != nullptr) { + if (child->widget()) { + child->widget()->deleteLater(); + } + delete child; + } + + // Rebuild + usernameLabel = new QLabel(user.displayUser.displayName, this); + usernameLabel->setStyleSheet( + "QLabel {" + " color: #FFFFFF;" + " font-weight: bold;" + " font-size: 14px;" + "}"); + usernameLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + usernameLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + usernameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); - // TODO: + nameRowLayout->addWidget(usernameLabel, 0, Qt::AlignLeft | Qt::AlignVCenter); - // this->updateGeometry(); - // this->repaint(); + QString checkingRes = OneSevenLiveUtility::checkingLevelBadgeResource(user); + if (!checkingRes.isEmpty()) { + QPixmap checkingPm; + if (checkingPm.load(checkingRes)) { + QLabel *checkingLabel = new QLabel(this); + checkingLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + checkingLabel->setStyleSheet("QLabel { background-color: transparent; }"); + int badgeHeight = 16; + checkingLabel->setPixmap( + checkingPm.scaledToHeight(badgeHeight, Qt::SmoothTransformation)); + checkingLabel->setFixedHeight(badgeHeight); + checkingLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + nameRowLayout->addWidget(checkingLabel, 0, Qt::AlignVCenter); + } + } } + +void OneSevenLiveRockViewerItem::updateBadges() { + // If badge row didn't exist but now might, we have a problem because we need to insert it into + // rightLayout But rightLayout is available. + + if (badgeRowLayout) { + // Clear existing + QLayoutItem *child; + while ((child = badgeRowLayout->takeAt(0)) != nullptr) { + if (child->widget()) { + child->widget()->deleteLater(); + } + delete child; + } + + // Check if we still need badges + // If not, we should ideally remove the layout, but keeping an empty layout is okay-ish + // (just extra spacing) Or we can delete badgeRowLayout and set to nullptr. + + // Let's see setupBadgeRow logic. + bool hasBadges = false; + for (int t : user.badgeTypes) { + const QString labelText = + OneSevenLiveUtility::badgeLabel(t, user.armyInfo.rank, &armyNameResponse); + if (!labelText.isEmpty()) { + hasBadges = true; + break; + } + } + + if (!hasBadges) { + // Remove layout from rightLayout + rightLayout->removeItem(badgeRowLayout); + badgeRowLayout->deleteLater(); + badgeRowLayout = nullptr; + return; + } + + // Rebuild + for (int t : user.badgeTypes) { + const QString labelText = + OneSevenLiveUtility::badgeLabel(t, user.armyInfo.rank, &armyNameResponse); + if (labelText.isEmpty()) { + continue; + } + + QWidget *badge = new QWidget(this); + QHBoxLayout *badgeLayout = new QHBoxLayout(badge); + badgeLayout->setContentsMargins(0, 0, 0, 0); + badgeLayout->setSpacing(0); + + QLabel *leftLbl = new QLabel(labelText, badge); + leftLbl->setStyleSheet( + "QLabel {" + " color: #FFFFFF;" + " padding: 2px 6px;" + " font-size: 11px;" + " background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #F69355, stop:1 " + "#F5487D);" + " border-top-left-radius: 6px;" + " border-bottom-left-radius: 6px;" + " border-top-right-radius: 0px;" + " border-bottom-right-radius: 0px;" + "}"); + leftLbl->setAttribute(Qt::WA_TransparentForMouseEvents, true); + leftLbl->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + const int targetH = leftLbl->sizeHint().height(); + leftLbl->setFixedHeight(targetH); + + QLabel *rightImg = new QLabel(badge); + QIcon badgeIcon(":/resources/user_images/ig_rock_viewer_badge.svg"); + const int iconH = targetH; + const int iconW = qRound(iconH * (8.0 / 14.0)); + rightImg->setPixmap(badgeIcon.pixmap(iconW, iconH)); + rightImg->setFixedSize(iconW, iconH); + rightImg->setAlignment(Qt::AlignCenter); + rightImg->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + badgeLayout->addWidget(leftLbl); + badgeLayout->addWidget(rightImg); + badgeRowLayout->addWidget(badge, 0, Qt::AlignLeft); + } + + } else { + // Create new if needed + badgeRowLayout = setupBadgeRow(); + if (badgeRowLayout) { + // Insert between nameRow (index 0) and pointsRow (index 1 or 2?) + // rightLayout has: nameRow, [badgeRow], pointsRow + // If badgeRow was null, pointsRow is at index 1. + // So insert at index 1. + rightLayout->insertLayout(1, badgeRowLayout); + } + } +} \ No newline at end of file diff --git a/src/17live/OneSevenLiveRockViewerItem.hpp b/src/17live/rockzone/OneSevenLiveRockViewerItem.hpp similarity index 75% rename from src/17live/OneSevenLiveRockViewerItem.hpp rename to src/17live/rockzone/OneSevenLiveRockViewerItem.hpp index 1f06dbd..f9c19d7 100644 --- a/src/17live/OneSevenLiveRockViewerItem.hpp +++ b/src/17live/rockzone/OneSevenLiveRockViewerItem.hpp @@ -39,16 +39,25 @@ class OneSevenLiveRockViewerItem : public QWidget { void mousePressEvent(QMouseEvent *event) override; private: - QLabel *usernameLabel; + QLabel *usernameLabel = nullptr; + QLabel *avatarLabel = nullptr; + QLabel *pointsLabel = nullptr; + QHBoxLayout *badgeRowLayout = nullptr; + QHBoxLayout *nameRowLayout = nullptr; + QVBoxLayout *rightLayout = nullptr; OneSevenLiveRockZoneViewer user; - OneSevenLiveApiWrappers *apiWrapper; - OneSevenLiveConfigManager *configManager; + OneSevenLiveApiWrappers *apiWrapper = nullptr; + OneSevenLiveConfigManager *configManager = nullptr; OneSevenLiveArmyNameResponse armyNameResponse; static QString buildUrl(const QString &path); void setupUi(); - QLabel *setupAvatar(); + void setupAvatar(); QHBoxLayout *setupNameRow(); QHBoxLayout *setupBadgeRow(); + QHBoxLayout *setupPointsRow(); + void reloadAvatar(); + void updateBadges(); + void updateNameRow(); }; diff --git a/src/17live/OneSevenLiveRockZoneDock.cpp b/src/17live/rockzone/OneSevenLiveRockZoneDock.cpp similarity index 55% rename from src/17live/OneSevenLiveRockZoneDock.cpp rename to src/17live/rockzone/OneSevenLiveRockZoneDock.cpp index 8de2b8e..54b50cc 100644 --- a/src/17live/OneSevenLiveRockZoneDock.cpp +++ b/src/17live/rockzone/OneSevenLiveRockZoneDock.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -31,20 +32,12 @@ OneSevenLiveRockZoneDock::OneSevenLiveRockZoneDock(QWidget* parent, setupUi(); createConnections(); - // Initialize auto refresh timer - refreshTimer = new QTimer(this); - refreshTimer->setInterval(5000); // 5 seconds - connect(refreshTimer, &QTimer::timeout, this, &OneSevenLiveRockZoneDock::refreshUserList); - // Initialize cooldown timer cooldownTimer = new QTimer(this); cooldownTimer->setInterval(1000); // 1 second connect(cooldownTimer, &QTimer::timeout, this, &OneSevenLiveRockZoneDock::onCooldownTimerTimeout); - refreshUserList(); - refreshTimer->start(); - connect(this, &QDockWidget::topLevelChanged, this, &OneSevenLiveRockZoneDock::handleTopLevelChanged); @@ -125,7 +118,13 @@ void OneSevenLiveRockZoneDock::setupUi() { userList->setSpacing(1); mainLayout->addWidget(userList); - mainLayout->addSpacing(40); + // Create empty list placeholder + emptyListLabel = new QLabel(obs_module_text("RockZone.EmptyList"), container); + emptyListLabel->setAlignment(Qt::AlignCenter); + emptyListLabel->setStyleSheet("QLabel { color: #999999; font-size: 14px; }"); + emptyListLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + emptyListLabel->setVisible(false); + mainLayout->addWidget(emptyListLabel); // Create bottom button pokeAllButton = new QPushButton(obs_module_text("RockZone.PokeAll")); @@ -145,6 +144,7 @@ void OneSevenLiveRockZoneDock::setupUi() { "}"); pokeAllButton->setMaximumWidth(250); pokeAllButton->setMinimumWidth(150); + pokeAllButton->setFixedHeight(40); pokeAllButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); mainLayout->addWidget(pokeAllButton, 0, Qt::AlignHCenter); @@ -152,8 +152,9 @@ void OneSevenLiveRockZoneDock::setupUi() { originalButtonText = pokeAllButton->text(); // Set dock size constraints to allow width adjustment with maximum width of 450 + // Minimum width adjusted to 320px to accommodate 280px RockViewerItem + margins setMaximumWidth(450); - setMinimumWidth(380); + setMinimumWidth(320); container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setWidget(container); @@ -162,34 +163,47 @@ void OneSevenLiveRockZoneDock::setupUi() { void OneSevenLiveRockZoneDock::createConnections() { connect(pokeAllButton, &QPushButton::clicked, this, &OneSevenLiveRockZoneDock::onPokeAllClicked); - - // Connect user list item click signal - // Disabled because OneSevenLiveRockViewerItem handles click and opens the dialog - // connect(userList, &QListWidget::itemClicked, this, - // &OneSevenLiveRockZoneDock::onUserItemClicked); } void OneSevenLiveRockZoneDock::updateUserItem( QListWidgetItem* item, const OneSevenLiveRockZoneViewer& user, const OneSevenLiveArmyNameResponse& armyNameResponse) { + // Create a mutable copy to apply fallback logic if needed + OneSevenLiveRockZoneViewer displayUser = user; + + // Fallback: If display name is empty, try to use giftRankOne info + if (displayUser.displayUser.displayName.trimmed().isEmpty() && + !displayUser.giftRankOne.displayName.trimmed().isEmpty()) { + displayUser.displayUser.displayName = displayUser.giftRankOne.displayName; + if (displayUser.displayUser.picture.isEmpty()) { + displayUser.displayUser.picture = displayUser.giftRankOne.picture; + } + } + OneSevenLiveRockViewerItem* w = qobject_cast(userList->itemWidget(item)); if (!w) { - w = new OneSevenLiveRockViewerItem(user, apiWrapper, configManager, armyNameResponse, this); + w = new OneSevenLiveRockViewerItem(displayUser, apiWrapper, configManager, armyNameResponse, + this); item->setSizeHint(w->sizeHint()); userList->setItemWidget(item, w); connect(w, &OneSevenLiveRockViewerItem::clicked, this, [this](const OneSevenLiveRockZoneViewer& viewer) { - OneSevenLiveUserDialog* dialog = - new OneSevenLiveUserDialog(this, apiWrapper, configManager); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setUserInfo(viewer); - dialog->show(); + obs_log(LOG_INFO, "OneSevenLiveRockZoneDock::userClicked %s", + viewer.displayUser.displayName.toStdString().c_str()); + if (!userDialog) { + obs_log(LOG_INFO, "Creating user dialog"); + userDialog = new OneSevenLiveUserDialog(this, apiWrapper, configManager); + } + + userDialog->setAttribute(Qt::WA_DeleteOnClose); + userDialog->setUserInfo(viewer); + userDialog->show(); }); } else { - w->updateData(user, armyNameResponse); + w->updateData(displayUser, armyNameResponse); } } @@ -199,16 +213,29 @@ void OneSevenLiveRockZoneDock::resizeEvent(QResizeEvent* event) { if (!userList) return; - for (int i = 0; i < userList->count(); ++i) { + // Store count to avoid issues if list is modified during iteration + int itemCount = userList->count(); + + for (int i = 0; i < itemCount; ++i) { + // Double-check count hasn't changed during iteration + if (i >= userList->count()) + break; + QListWidgetItem* item = userList->item(i); + if (!item) + continue; // Skip null items + QWidget* widget = userList->itemWidget(item); - if (widget) + if (widget) { widget->resize(userList->viewport()->width(), widget->height()); - item->setSizeHint(widget->sizeHint()); + item->setSizeHint(widget->sizeHint()); + } } } void OneSevenLiveRockZoneDock::refreshUserList() { + if (!apiWrapper) + return; std::string roomID; configManager->getConfigValue("RoomID", roomID); @@ -220,20 +247,55 @@ void OneSevenLiveRockZoneDock::refreshUserList() { QObject* worker = new QObject; worker->moveToThread(thread); + connect(this, &QObject::destroyed, thread, &QThread::quit); connect(thread, &QThread::started, worker, [this, worker, thread, roomID, userID]() { + if (!apiWrapper) { + QMetaObject::invokeMethod( + this, + [this]() { + obs_log(LOG_ERROR, "[RockZone] apiWrapper unavailable, abort refresh"); + }, + Qt::QueuedConnection); + thread->quit(); + worker->deleteLater(); + return; + } // Execute API call in new thread - Json response; - bool success = apiWrapper->GetRockViewers(roomID, response); + Json jsonResponse; + bool success = false; + + // Try to load mock data first + // QFile mockFile("/Users/zhuyu/workspace/mk/17live/dev/obs-17live/temp/rock3.json"); + // if (mockFile.exists() && mockFile.open(QIODevice::ReadOnly)) { + // try { + // QByteArray data = mockFile.readAll(); + // jsonResponse = Json::parse(data.toStdString()); + // success = true; + // obs_log(LOG_INFO, "Loaded mock rock viewers data"); + // } catch (...) { + // obs_log(LOG_ERROR, "Failed to parse mock rock viewers data"); + // } + // mockFile.close(); + // } + + if (!success) { + success = apiWrapper->GetRockViewers(roomID, jsonResponse); + } + + Json response = jsonResponse; OneSevenLiveArmyNameResponse armyNameResponse; // Only call GetArmyName if not cached - if (!armyNameCached) { - apiWrapper->GetArmyName(userID, armyNameResponse); - cachedArmyNameResponse = armyNameResponse; - armyNameCached = true; - } else { - armyNameResponse = cachedArmyNameResponse; + { + QMutexLocker locker(&armyNameMutex); + if (!armyNameCached) { + apiWrapper->GetArmyName(userID, armyNameResponse); + cachedArmyNameResponse = armyNameResponse; + armyNameCached = true; + } else { + armyNameResponse = cachedArmyNameResponse; + } } // Use Qt::QueuedConnection to ensure UI updates happen on the main thread @@ -248,33 +310,63 @@ void OneSevenLiveRockZoneDock::refreshUserList() { QHash idIndex; // userID -> index in viewersList viewersList.clear(); for (const auto& user : users) { - const QString uid = user.displayUser.userID.isEmpty() - ? user.giftRankOne.userID - : user.displayUser.userID; + QString uid; + QString displayName; + QString picture; + + // Determine user info based on type + if (user.type == 3) { // Army + uid = user.armyInfo.user.userID; + displayName = user.armyInfo.user.displayName; + picture = user.armyInfo.user.picture; + } else if (user.type == 2) { // Guardian + uid = user.guardian.owner.userID; + displayName = user.guardian.owner.displayName; + picture = user.guardian.owner.picture; + } else if (user.type == 1) { // GiftRankOne + uid = user.giftRankOne.userID; + displayName = user.giftRankOne.displayName; + picture = user.giftRankOne.picture; + } else { // Type 0 or others + uid = user.displayUser.userID; + displayName = user.displayUser.displayName; + picture = user.displayUser.picture; + } + if (uid.isEmpty()) { continue; } if (uid == QString::fromStdString(userID)) { + obs_log(LOG_INFO, "Skipping viewer: matches current user"); continue; } + + if (user.anonymousInfo.isInvisible) { + obs_log(LOG_INFO, "Skipping viewer: isInvisible is true"); + continue; + } + if (user.userAttr.sentPoint <= 0) { + obs_log(LOG_INFO, "Skipping viewer: sentPoint <= 0"); + continue; + } + + if (displayName.trimmed().isEmpty()) { continue; } + if (idIndex.contains(uid)) { auto& existing = viewersList[idIndex.value(uid)]; if (!existing.badgeTypes.contains(user.type)) { existing.badgeTypes.append(user.type); } - if (!existing.giftRankOne.userID.isEmpty()) { - existing.displayUser = user.displayUser; - } } else { OneSevenLiveRockZoneViewer base = user; - if (base.displayUser.userID.isEmpty()) { - base.displayUser.userID = base.giftRankOne.userID; - base.displayUser.displayName = base.giftRankOne.displayName; - base.displayUser.picture = base.giftRankOne.picture; - } + + // Force populate displayUser with the extracted info + base.displayUser.userID = uid; + base.displayUser.displayName = displayName; + base.displayUser.picture = picture; base.badgeTypes.clear(); base.badgeTypes.append(user.type); @@ -283,24 +375,49 @@ void OneSevenLiveRockZoneDock::refreshUserList() { } } + QList sortedViewersList = + SortOneSevenLiveRockZoneViewers(viewersList); + // Update UI - userList->setVisible(true); + // userList->setVisible(true); // --- Incremental Update Section --- QSet newUserIDs; - for (const auto& user : viewersList) { + + // Limit to first 50 viewers to improve performance + if (sortedViewersList.size() > 50) { + sortedViewersList = sortedViewersList.mid(0, 50); + } + + // First pass: update existing items and create new ones + for (int i = 0; i < sortedViewersList.size(); ++i) { + const auto& user = sortedViewersList[i]; QString uid = user.displayUser.userID; newUserIDs.insert(uid); + QListWidgetItem* item = nullptr; if (userItemMap.contains(uid)) { - // Existing user, update item - QListWidgetItem* item = userItemMap.value(uid); + // Existing user + item = userItemMap.value(uid); + + // Move item to correct position if needed + int currentRow = userList->row(item); + if (currentRow != i && currentRow >= 0) { + // Use a more atomic operation to avoid temporary null items + QListWidgetItem* takenItem = userList->takeItem(currentRow); + if (takenItem == item) { + userList->insertItem(i, item); + } + } + // Update existing item or recreate widget if it was destroyed by + // takeItem updateUserItem(item, user, armyNameResponse); } else { // New user - QListWidgetItem* item = new QListWidgetItem(userList); + item = new QListWidgetItem(); + // Must insert item BEFORE setting widget, otherwise setItemWidget fails + userList->insertItem(i, item); updateUserItem(item, user, armyNameResponse); - userList->addItem(item); userItemMap.insert(uid, item); } } @@ -312,14 +429,46 @@ void OneSevenLiveRockZoneDock::refreshUserList() { QListWidgetItem* item = it.value(); int row = userList->row(item); if (row >= 0) { + QWidget* w = userList->itemWidget(item); + if (w) { + userList->removeItemWidget(item); + delete w; + } QListWidgetItem* removed = userList->takeItem(row); - delete removed; + if (removed) { + delete removed; + } } it = userItemMap.erase(it); } else { ++it; } } + + // Update empty state visibility + bool isEmpty = sortedViewersList.isEmpty(); + if (userList) + userList->setVisible(!isEmpty); + if (emptyListLabel) + emptyListLabel->setVisible(isEmpty); + + // Safety: if list should be empty but has items, clear it to prevent ghost + // items + if (isEmpty && userList && userList->count() > 0) { + int count = userList->count(); + for (int i = 0; i < count; ++i) { + QListWidgetItem* item = userList->item(i); + if (!item) + continue; + QWidget* w = userList->itemWidget(item); + if (w) { + userList->removeItemWidget(item); + delete w; + } + } + userList->clear(); + userItemMap.clear(); + } } else { // Show error message obs_log(LOG_ERROR, "Failed to refresh rock viewers list: %s", @@ -338,10 +487,33 @@ void OneSevenLiveRockZoneDock::refreshUserList() { } void OneSevenLiveRockZoneDock::clearArmyNameCache() { + QMutexLocker locker(&armyNameMutex); armyNameCached = false; cachedArmyNameResponse = OneSevenLiveArmyNameResponse(); } +void OneSevenLiveRockZoneDock::clearUserList() { + if (userList) { + int count = userList->count(); + for (int i = 0; i < count; ++i) { + QListWidgetItem* item = userList->item(i); + if (!item) + continue; + QWidget* w = userList->itemWidget(item); + if (w) { + userList->removeItemWidget(item); + delete w; + } + } + userList->clear(); + userList->setVisible(false); + } + userItemMap.clear(); + if (emptyListLabel) { + emptyListLabel->setVisible(true); + } +} + void OneSevenLiveRockZoneDock::onPokeAllClicked() { if (!apiWrapper) { return; @@ -392,26 +564,6 @@ void OneSevenLiveRockZoneDock::handleTopLevelChanged(bool topLevel) { } } -void OneSevenLiveRockZoneDock::onUserItemClicked(QListWidgetItem* item) { - // Get clicked item index - int index = userList->row(item); - if (index < 0 || index >= viewersList.size()) { - return; - } - - // Get user information - const OneSevenLiveRockZoneViewer& user = viewersList.at(index); - - // Create user information dialog (if it doesn't exist) - if (!userDialog) { - userDialog = new OneSevenLiveUserDialog(this, apiWrapper, configManager); - } - - // Set user information and display dialog - userDialog->setUserInfo(user); - userDialog->exec(); -} - void OneSevenLiveRockZoneDock::onCooldownTimerTimeout() { cooldownSeconds--; diff --git a/src/17live/OneSevenLiveRockZoneDock.hpp b/src/17live/rockzone/OneSevenLiveRockZoneDock.hpp similarity index 89% rename from src/17live/OneSevenLiveRockZoneDock.hpp rename to src/17live/rockzone/OneSevenLiveRockZoneDock.hpp index 459df8a..3b93477 100644 --- a/src/17live/OneSevenLiveRockZoneDock.hpp +++ b/src/17live/rockzone/OneSevenLiveRockZoneDock.hpp @@ -5,15 +5,17 @@ #include #include #include +#include +#include #include #include #include -#include "OneSevenLiveUserDialog.hpp" #include "api/OneSevenLiveModels.hpp" class OneSevenLiveApiWrappers; class OneSevenLiveConfigManager; +class OneSevenLiveUserDialog; class OneSevenLiveRockZoneDock : public QDockWidget { Q_OBJECT @@ -26,6 +28,7 @@ class OneSevenLiveRockZoneDock : public QDockWidget { void refreshUserList(); void clearArmyNameCache(); + void clearUserList(); protected: void resizeEvent(QResizeEvent* event) override; @@ -36,7 +39,6 @@ class OneSevenLiveRockZoneDock : public QDockWidget { private slots: void onPokeAllClicked(); void handleTopLevelChanged(bool topLevel); - void onUserItemClicked(QListWidgetItem* item); void onCooldownTimerTimeout(); private: @@ -46,6 +48,7 @@ class OneSevenLiveRockZoneDock : public QDockWidget { const OneSevenLiveArmyNameResponse& armyNameResponse); QListWidget* userList; + QLabel* emptyListLabel = nullptr; QPushButton* pokeAllButton; OneSevenLiveApiWrappers* apiWrapper = nullptr; @@ -57,12 +60,10 @@ class OneSevenLiveRockZoneDock : public QDockWidget { // Cached army name response to avoid repeated API calls OneSevenLiveArmyNameResponse cachedArmyNameResponse; bool armyNameCached = false; + QMutex armyNameMutex; // User information dialog - OneSevenLiveUserDialog* userDialog = nullptr; - - // Auto refresh timer - QTimer* refreshTimer = nullptr; + QPointer userDialog; // Cooldown timer for poke all button QTimer* cooldownTimer = nullptr; diff --git a/src/17live/OneSevenLiveUserDialog.cpp b/src/17live/rockzone/OneSevenLiveUserDialog.cpp similarity index 100% rename from src/17live/OneSevenLiveUserDialog.cpp rename to src/17live/rockzone/OneSevenLiveUserDialog.cpp diff --git a/src/17live/OneSevenLiveUserDialog.hpp b/src/17live/rockzone/OneSevenLiveUserDialog.hpp similarity index 100% rename from src/17live/OneSevenLiveUserDialog.hpp rename to src/17live/rockzone/OneSevenLiveUserDialog.hpp diff --git a/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.cpp b/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.cpp new file mode 100644 index 0000000..5a2836a --- /dev/null +++ b/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.cpp @@ -0,0 +1,307 @@ +#include "OneSevenLiveLoadRoomInfoWorker.hpp" + +#include + +#include "OneSevenLiveConfigManager.hpp" +#include "api/OneSevenLiveApiWrappers.hpp" +#include "plugin-support.h" +#include "utility/Common.hpp" + +OneSevenLiveLoadRoomInfoWorker::OneSevenLiveLoadRoomInfoWorker( + OneSevenLiveApiWrappers* apiWrapper, OneSevenLiveConfigManager* configManager) + : m_apiWrapper(apiWrapper), + m_configManager(configManager), + m_roomInfo(nullptr), + m_configStreamer(nullptr), + m_userInfo(nullptr), + m_levels(nullptr) { + if (!m_apiWrapper) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: apiWrapper is null"); + } + + if (!m_configManager) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: configManager is null"); + } +} + +void OneSevenLiveLoadRoomInfoWorker::setDataStructures(OneSevenLiveRoomInfo* roomInfo, + OneSevenLiveConfigStreamer* configStreamer, + OneSevenLiveUserInfo* userInfo, + OneSevenLiveArmySubscriptionLevels* levels) { + m_roomInfo = roomInfo; + m_configStreamer = configStreamer; + m_userInfo = userInfo; + m_levels = levels; +} + +OneSevenLiveLoadRoomInfoWorker::LoadResult OneSevenLiveLoadRoomInfoWorker::loadRoomInfo( + qint64 roomID) { + obs_log(LOG_INFO, + "OneSevenLiveLoadRoomInfoWorker: Starting to load room info for room ID: %lld", roomID); + + LoadResult result; + + // Validate prerequisites + if (!m_apiWrapper) { + result.errorMessage = "API wrapper is not available"; + return result; + } + + if (!m_configManager) { + result.errorMessage = "Config manager is not available"; + return result; + } + + if (!m_roomInfo || !m_configStreamer || !m_userInfo || !m_levels) { + result.errorMessage = "Data structures are not properly initialized"; + return result; + } + + if (roomID <= 0) { + result.errorMessage = "Invalid room ID provided"; + return result; + } + + try { + // Step 1: Get configuration values + std::string region, language, userID; + if (!getConfigurationValues(region, language, userID)) { + result.errorMessage = "Failed to retrieve configuration values"; + return result; + } + + obs_log( + LOG_INFO, + "OneSevenLiveLoadRoomInfoWorker: Config values - Region: %s, Language: %s, UserID: %s", + region.c_str(), language.c_str(), userID.c_str()); + + // Step 2: Load room information + try { + result.roomInfoSuccess = m_apiWrapper->GetRoomInfo(roomID, *m_roomInfo); + if (!result.roomInfoSuccess) { + obs_log(LOG_WARNING, "OneSevenLiveLoadRoomInfoWorker: Failed to get room info"); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Exception in GetRoomInfo: %s", + e.what()); + result.roomInfoSuccess = false; + } catch (...) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Unknown exception in GetRoomInfo"); + result.roomInfoSuccess = false; + } + + // if room info failed, set default values + // landscape: false + // streamerType: 0 + // archiveConfig + // autoRecording: true + // autoPublish: false + // clipPermission: 0 + if (!result.roomInfoSuccess) { + obs_log(LOG_WARNING, + "OneSevenLiveLoadRoomInfoWorker: GetRoomInfo failed, setting default values"); + m_roomInfo->landscape = false; + m_roomInfo->streamerType = 0; + m_roomInfo->archiveConfig = OneSevenLiveArchiveConfig(); + m_roomInfo->archiveConfig.autoRecording = true; + m_roomInfo->archiveConfig.autoPublish = false; + m_roomInfo->archiveConfig.clipPermission = 0; + m_roomInfo->status = 0; + result.roomInfoSuccess = true; + } + + // Step 3: Load config streamer information + try { + result.configStreamerSuccess = + m_apiWrapper->GetConfigStreamer(region, language, *m_configStreamer); + if (!result.configStreamerSuccess) { + obs_log(LOG_WARNING, + "OneSevenLiveLoadRoomInfoWorker: Failed to get config streamer"); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Exception in GetConfigStreamer: %s", + e.what()); + result.configStreamerSuccess = false; + } catch (...) { + obs_log(LOG_ERROR, + "OneSevenLiveLoadRoomInfoWorker: Unknown exception in GetConfigStreamer"); + result.configStreamerSuccess = false; + } + + // Step 4: Load user information + try { + result.userInfoSuccess = + m_apiWrapper->GetUserInfo(userID, region, language, *m_userInfo); + if (!result.userInfoSuccess) { + obs_log(LOG_WARNING, "OneSevenLiveLoadRoomInfoWorker: Failed to get user info"); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Exception in GetUserInfo: %s", + e.what()); + result.userInfoSuccess = false; + } catch (...) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Unknown exception in GetUserInfo"); + result.userInfoSuccess = false; + } + + // Step 5: Load army subscription levels + try { + result.levelsSuccess = + m_apiWrapper->GetArmySubscriptionLevels(region, language, *m_levels); + if (!result.levelsSuccess) { + obs_log(LOG_WARNING, + "OneSevenLiveLoadRoomInfoWorker: Failed to get army subscription levels"); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, + "OneSevenLiveLoadRoomInfoWorker: Exception in GetArmySubscriptionLevels: %s", + e.what()); + result.levelsSuccess = false; + } catch (...) { + obs_log( + LOG_ERROR, + "OneSevenLiveLoadRoomInfoWorker: Unknown exception in GetArmySubscriptionLevels"); + result.levelsSuccess = false; + } + + // Step 6: Validate loaded data + if (!validateLoadedData(result)) { + obs_log(LOG_WARNING, "OneSevenLiveLoadRoomInfoWorker: Data validation failed"); + } + + // Step 7: Generate error message if needed + if (result.hasFailures()) { + result.errorMessage = generateErrorMessage(result); + } + + obs_log(LOG_INFO, + "OneSevenLiveLoadRoomInfoWorker: Loading completed - Required data: %s, Optional " + "data: %s", + result.hasRequiredData() ? "OK" : "FAILED", + result.hasOptionalData() ? "OK" : "PARTIAL"); + + } catch (const std::exception& e) { + result.errorMessage = std::string("Critical error during loading: ") + e.what(); + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: %s", result.errorMessage.c_str()); + } catch (...) { + result.errorMessage = "Unknown critical error during loading"; + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: %s", result.errorMessage.c_str()); + } + + return result; +} + +bool OneSevenLiveLoadRoomInfoWorker::getConfigurationValues(std::string& region, + std::string& language, + std::string& userID) { + try { + // Get region + if (!m_configManager->getConfigValue("Region", region)) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Failed to get Region from config"); + return false; + } + + // Get language + language = GetCurrentLanguage(); + if (language.empty()) { + obs_log(LOG_WARNING, + "OneSevenLiveLoadRoomInfoWorker: Language is empty, using default"); + language = "en-US"; // Default fallback + } + + // Get user ID + if (!m_configManager->getConfigValue("UserID", userID)) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: Failed to get UserID from config"); + return false; + } + + if (userID.empty()) { + obs_log(LOG_ERROR, "OneSevenLiveLoadRoomInfoWorker: UserID is empty"); + return false; + } + + return true; + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, + "OneSevenLiveLoadRoomInfoWorker: Exception in getConfigurationValues: %s", + e.what()); + return false; + } catch (...) { + obs_log(LOG_ERROR, + "OneSevenLiveLoadRoomInfoWorker: Unknown exception in getConfigurationValues"); + return false; + } +} + +bool OneSevenLiveLoadRoomInfoWorker::validateLoadedData(const LoadResult& result) { + bool isValid = true; + + // Validate room info if it was loaded successfully + if (result.roomInfoSuccess && m_roomInfo) { + if (m_roomInfo->liveStreamID <= 0) { + obs_log(LOG_WARNING, + "OneSevenLiveLoadRoomInfoWorker: Invalid liveStreamID in room info"); + isValid = false; + } + } + + // Validate config streamer if it was loaded successfully + if (result.configStreamerSuccess && m_configStreamer) { + if (m_configStreamer->subtabs.empty()) { + obs_log(LOG_WARNING, "OneSevenLiveLoadRoomInfoWorker: No subtabs in config streamer"); + isValid = false; + } + } + + // Validate user info if it was loaded successfully + if (result.userInfoSuccess && m_userInfo) { + if (m_userInfo->userID.isEmpty()) { + obs_log(LOG_WARNING, "OneSevenLiveLoadRoomInfoWorker: Empty userID in user info"); + isValid = false; + } + } + + return isValid; +} + +std::string OneSevenLiveLoadRoomInfoWorker::generateErrorMessage(const LoadResult& result) { + std::vector failedOperations; + + if (!result.roomInfoSuccess) { + failedOperations.push_back("room information"); + } + + if (!result.configStreamerSuccess) { + failedOperations.push_back("streamer configuration"); + } + + if (!result.userInfoSuccess) { + failedOperations.push_back("user information"); + } + + if (!result.levelsSuccess) { + failedOperations.push_back("army subscription levels"); + } + + if (failedOperations.empty()) { + return std::string(); + } + + std::string baseMessage = "Failed to load: "; + for (size_t i = 0; i < failedOperations.size(); ++i) { + if (i > 0) + baseMessage += ", "; + baseMessage += failedOperations[i]; + } + + // Add API error message if available + if (m_apiWrapper) { + std::string apiError = m_apiWrapper->getLastErrorMessage().toStdString(); + if (!apiError.empty()) { + baseMessage += "\nAPI Error: " + apiError; + } + } + + return baseMessage; +} diff --git a/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.hpp b/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.hpp new file mode 100644 index 0000000..819a9a2 --- /dev/null +++ b/src/17live/streaming/OneSevenLiveLoadRoomInfoWorker.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include "api/OneSevenLiveModels.hpp" + +// Forward declarations +class OneSevenLiveApiWrappers; +class OneSevenLiveConfigManager; + +/** + * @brief Simple data structure without Qt dependencies + */ +struct OneSevenLiveLoadResult { + bool roomInfoSuccess = false; + bool configStreamerSuccess = false; + bool userInfoSuccess = false; + bool levelsSuccess = false; + std::string errorMessage; + + OneSevenLiveLoadResult() = default; + + OneSevenLiveLoadResult(bool roomInfo, bool configStreamer, bool userInfo, bool levels, + const std::string& error = std::string()) + : roomInfoSuccess(roomInfo), + configStreamerSuccess(configStreamer), + userInfoSuccess(userInfo), + levelsSuccess(levels), + errorMessage(error) {} + + /** + * @brief Check if all required data was loaded successfully + * @return true if room info and config streamer are both successful + */ + bool hasRequiredData() const { + return roomInfoSuccess && configStreamerSuccess; + } + + /** + * @brief Check if all optional data was loaded successfully + * @return true if user info and levels are both successful + */ + bool hasOptionalData() const { + return userInfoSuccess && levelsSuccess; + } + + /** + * @brief Check if any API call failed + * @return true if any API call failed + */ + bool hasFailures() const { + return !roomInfoSuccess || !configStreamerSuccess || !userInfoSuccess || !levelsSuccess; + } +}; + +/** + * @brief Worker class for loading room information without Qt dependencies + * + * This class handles all API calls related to loading room information, + * including room info, config streamer, user info, and army subscription levels. + * It provides comprehensive error handling without Qt signal/slot mechanism. + */ +class OneSevenLiveLoadRoomInfoWorker { + public: + using LoadResult = OneSevenLiveLoadResult; + + explicit OneSevenLiveLoadRoomInfoWorker(OneSevenLiveApiWrappers* apiWrapper, + OneSevenLiveConfigManager* configManager); + + ~OneSevenLiveLoadRoomInfoWorker() = default; + + /** + * @brief Start loading room information + * @param roomID The room ID to load information for + * @return LoadResult containing the results of all API calls + */ + LoadResult loadRoomInfo(std::int64_t roomID); + + /** + * @brief Set the data structures that will be populated + * This must be called before starting the loading process + */ + void setDataStructures(OneSevenLiveRoomInfo* roomInfo, + OneSevenLiveConfigStreamer* configStreamer, + OneSevenLiveUserInfo* userInfo, + OneSevenLiveArmySubscriptionLevels* levels); + + private: + OneSevenLiveApiWrappers* m_apiWrapper = nullptr; + OneSevenLiveConfigManager* m_configManager = nullptr; + + // Data structures to hold the loaded information + OneSevenLiveRoomInfo* m_roomInfo = nullptr; + OneSevenLiveConfigStreamer* m_configStreamer = nullptr; + OneSevenLiveUserInfo* m_userInfo = nullptr; + OneSevenLiveArmySubscriptionLevels* m_levels = nullptr; + + /** + * @brief Get configuration values needed for API calls + * @param region Output parameter for region + * @param language Output parameter for language + * @param userID Output parameter for user ID + * @return true if all config values were retrieved successfully + */ + bool getConfigurationValues(std::string& region, std::string& language, std::string& userID); + + /** + * @brief Validate the loaded data for consistency + * @param result The load result to validate + * @return true if data is valid and consistent + */ + bool validateLoadedData(const LoadResult& result); + + /** + * @brief Generate a comprehensive error message based on failed API calls + * @param result The load result containing failure information + * @return A user-friendly error message + */ + std::string generateErrorMessage(const LoadResult& result); +}; diff --git a/src/17live/streaming/OneSevenLiveStreamManager.cpp b/src/17live/streaming/OneSevenLiveStreamManager.cpp new file mode 100644 index 0000000..b02a9b2 --- /dev/null +++ b/src/17live/streaming/OneSevenLiveStreamManager.cpp @@ -0,0 +1,974 @@ +#include "OneSevenLiveStreamManager.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveConfigManager.hpp" +#include "api/OneSevenLiveApiWrappers.hpp" +#include "api/OneSevenLiveModels.hpp" +#include "moc_OneSevenLiveStreamManager.cpp" +#include "plugin-support.h" +#include "utility/Common.hpp" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WebsocketUtils.hpp" +#include "websocket/WsMessage.hpp" + +// Static callback for OBS frontend events to ensure safe registration/removal +static void ObsFrontendEventCallback(enum obs_frontend_event event, void* private_data) { + OneSevenLiveStreamManager* manager = static_cast(private_data); + if (!manager) + return; + + if (event == OBS_FRONTEND_EVENT_STREAMING_STOPPED) { + obs_output_t* output = obs_frontend_get_streaming_output(); + if (output) { + const char* err = obs_output_get_last_error(output); + manager->handleObsStreamStopped(0, err ? QString(err) : QString()); + obs_output_release(output); + } else { + manager->handleObsStreamStopped(0, QString()); + } + } +} + +OneSevenLiveStreamManager::OneSevenLiveStreamManager(OneSevenLiveApiWrappers* apiWrapper, + OneSevenLiveConfigManager* configManager, + QObject* parent) + : QObject(parent), + apiWrapper(apiWrapper), + configManager(configManager), + currentStreamingStatus(OneSevenLiveStreamingStatus::NotStarted) { + // Load current userID + std::string userID; + configManager->getConfigValue("UserID", userID); + currentUserID = userID; + + std::string roomIDStr; + configManager->getConfigValue("RoomID", roomIDStr); + currentRoomID = std::stoll(roomIDStr); + QTimer::singleShot(0, this, [this]() { loadRoomInfo(); }); + + m_statusTimer = new QTimer(this); + m_statusTimer->setSingleShot(false); + m_statusTimer->setInterval(10 * 1000); + connect(m_statusTimer, &QTimer::timeout, this, &OneSevenLiveStreamManager::onStatusTimer); + m_statusTimer->start(); + + // Register callback for OBS streaming events + obs_frontend_add_event_callback(ObsFrontendEventCallback, this); + + obs_log(LOG_INFO, "OneSevenLiveStreamManager initialized"); +} + +OneSevenLiveStreamManager::~OneSevenLiveStreamManager() { + obs_frontend_remove_event_callback(ObsFrontendEventCallback, this); +} + +bool OneSevenLiveStreamManager::fetchRtmpByProvider(const std::string& provider, + OneSevenLiveRtmpResponse& response) { + return apiWrapper && apiWrapper->GetRtmpByProvider(provider, response); +} + +QString OneSevenLiveStreamManager::getLastErrorMessage() const { + return apiWrapper ? apiWrapper->getLastErrorMessage() : QString("Unknown error"); +} + +bool OneSevenLiveStreamManager::startStreamWithWeb() { + obs_log(LOG_INFO, "Saving web stream settings"); + + if (!roomInfo.rtmpUrls.isEmpty()) { + QString provider = GetProviderNameByIndex(roomInfo.rtmpUrls[0].provider); + OneSevenLiveRtmpResponse rtmpResponse; + if (fetchRtmpByProvider(provider.toStdString(), rtmpResponse)) { + rtmpResponse.liveStreamID = QString::number(roomInfo.liveStreamID); + configureStreamingService(rtmpResponse); + + currentLiveStreamID = rtmpResponse.liveStreamID.toStdString(); + + OneSevenLiveRtmpRequest request; + request.userID = QString::fromStdString(currentUserID); + request.caption = roomInfo.caption; + request.device = "OBS"; + + qint64 selectedEventId = 0; + for (const auto& evt : roomInfo.eventList) { + if (evt.type == 2) { + selectedEventId = evt.ID; + break; + } + } + request.eventID = selectedEventId; + + QStringList tags; + for (const auto& t : roomInfo.lastUsedHashtags) { + tags << t.text; + } + request.hashtags = tags; + + request.landscape = roomInfo.landscape; + request.streamerType = roomInfo.streamerType; + request.subtabID = (roomInfo.subtabs.size() > 0) ? roomInfo.subtabs[0] : QString(); + request.archiveConfig = roomInfo.archiveConfig; + + OneSevenLiveVliverInfo vl; + vl.vliverModel = configStreamer.lastStreamState.vliverInfo.vliverModel; + request.vliverInfo = vl; + + OneSevenLiveArmy army{}; + army.enable = false; + army.requiredArmyRank = 0; + army.showOnHotPage = false; + army.armyOnlyPN = false; + request.armyOnly = army; + + request.enableOBSGroupCall = roomInfo.enableOBSGroupCall; + + currentStreamRequest = request; + currentStreamResponse = rtmpResponse; + OneSevenLiveStreamInfo info; + info.request = request; + info.categoryName = QString(); + info.createdAt = QDateTime::currentDateTime(); + info.streamUuid = rtmpResponse.streamID; + currentLiveStreamInfo = info; + + wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + } else { + obs_log(LOG_ERROR, "Failed to fetch rtmp url for provider %s", + provider.toStdString().c_str()); + return false; + } + } else { + obs_log(LOG_ERROR, "Empty rtmpUrl in roomInfo"); + return false; + } + + return true; +} + +void OneSevenLiveStreamManager::startStreamWithWebAsync() { + obs_log(LOG_INFO, "Saving web stream settings (Async)"); + + if (roomInfo.rtmpUrls.isEmpty()) { + obs_log(LOG_ERROR, "Empty rtmpUrl in roomInfo"); + emit webStreamSettingsLoaded(false); + return; + } + + QString provider = GetProviderNameByIndex(roomInfo.rtmpUrls[0].provider); + + auto* api = this->apiWrapper; + QPointer self = this; + std::string providerStr = provider.toStdString(); + + ScheduleOBSTask([self, api, providerStr]() { + if (!self) + return; + + OneSevenLiveRtmpResponse rtmpResponse; + bool success = false; + + if (api) { + success = api->GetRtmpByProvider(providerStr, rtmpResponse); + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, success, rtmpResponse, providerStr]() { + if (success) { + OneSevenLiveRtmpResponse resp = rtmpResponse; + resp.liveStreamID = QString::number(self->roomInfo.liveStreamID); + self->configureStreamingService(resp); + + self->currentLiveStreamID = resp.liveStreamID.toStdString(); + + OneSevenLiveRtmpRequest request; + request.userID = QString::fromStdString(self->currentUserID); + request.caption = self->roomInfo.caption; + request.device = "OBS"; + + qint64 selectedEventId = 0; + for (const auto& evt : self->roomInfo.eventList) { + if (evt.type == 2) { + selectedEventId = evt.ID; + break; + } + } + request.eventID = selectedEventId; + + QStringList tags; + for (const auto& t : self->roomInfo.lastUsedHashtags) { + tags << t.text; + } + request.hashtags = tags; + + request.landscape = self->roomInfo.landscape; + request.streamerType = self->roomInfo.streamerType; + request.subtabID = (self->roomInfo.subtabs.size() > 0) + ? self->roomInfo.subtabs[0] + : QString(); + request.archiveConfig = self->roomInfo.archiveConfig; + + OneSevenLiveVliverInfo vl; + vl.vliverModel = + self->configStreamer.lastStreamState.vliverInfo.vliverModel; + request.vliverInfo = vl; + + OneSevenLiveArmy army{}; + army.enable = false; + army.requiredArmyRank = 0; + army.showOnHotPage = false; + army.armyOnlyPN = false; + request.armyOnly = army; + + request.enableOBSGroupCall = self->roomInfo.enableOBSGroupCall; + + self->currentStreamRequest = request; + self->currentStreamResponse = resp; + OneSevenLiveStreamInfo info; + info.request = request; + info.categoryName = QString(); + info.createdAt = QDateTime::currentDateTime(); + info.streamUuid = resp.streamID; + self->currentLiveStreamInfo = info; + + self->wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + emit self->webStreamSettingsLoaded(true); + } else { + obs_log(LOG_ERROR, "Failed to fetch rtmp url for provider %s", + providerStr.c_str()); + emit self->webStreamSettingsLoaded(false); + } + }, + Qt::QueuedConnection); + } + }); +} + +bool OneSevenLiveStreamManager::createRtmp(const OneSevenLiveRtmpRequest& request) { + obs_log(LOG_INFO, "Creating live stream"); + + OneSevenLiveRtmpRequest modifiedRequest = request; + modifiedRequest.userID = QString::fromStdString(currentUserID); + modifiedRequest.streamerType = roomInfo.streamerType; + + OneSevenLiveRtmpResponse response; + if (!apiWrapper->CreateRtmp(modifiedRequest, response)) { + QString errorMsg = apiWrapper->getLastErrorMessage(); + obs_log(LOG_ERROR, "Failed to create stream. UserID: %s, Error: %s, Timestamp: %lld", + modifiedRequest.userID.toStdString().c_str(), + errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str(), + QDateTime::currentMSecsSinceEpoch()); + emit errorOccurred(errorMsg, "createLiveStream"); + return false; + } + + // Store the current stream ID and user ID + currentLiveStreamID = response.liveStreamID.toStdString(); + // Store full request/response and snapshot info + currentStreamRequest = modifiedRequest; + currentStreamResponse = response; + OneSevenLiveStreamInfo info; + info.request = modifiedRequest; + info.categoryName = QString(); + info.createdAt = QDateTime::currentDateTime(); + info.streamUuid = response.streamID; + currentLiveStreamInfo = info; + + // Update status + setCurrentStreamingStatus(OneSevenLiveStreamingStatus::Live); + + // Broadcast Ably chat connected when live is created + wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + obs_log(LOG_INFO, "Live stream created successfully. LiveStreamID: %s", + currentLiveStreamID.c_str()); + return true; +} + +void OneSevenLiveStreamManager::createRtmpAsync(const OneSevenLiveRtmpRequest& request) { + obs_log(LOG_INFO, "Creating live stream (Async)"); + + OneSevenLiveRtmpRequest modifiedRequest = request; + modifiedRequest.userID = QString::fromStdString(currentUserID); + modifiedRequest.streamerType = roomInfo.streamerType; + + // Capture apiWrapper pointer by value. It is owned by CoreManager. + auto* api = this->apiWrapper; + QPointer self = this; + + ScheduleOBSTask([self, modifiedRequest, api]() { + if (!self) + return; + + OneSevenLiveRtmpResponse response; + bool success = false; + QString errorMsg; + + if (api) { + success = api->CreateRtmp(modifiedRequest, response); + if (!success) { + errorMsg = api->getLastErrorMessage(); + } + } else { + errorMsg = "API Wrapper not initialized"; + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, success, errorMsg, modifiedRequest, response]() { + if (success) { + self->currentLiveStreamID = response.liveStreamID.toStdString(); + self->currentStreamRequest = modifiedRequest; + self->currentStreamResponse = response; + + OneSevenLiveStreamInfo info; + info.request = modifiedRequest; + info.categoryName = QString(); + info.createdAt = QDateTime::currentDateTime(); + info.streamUuid = response.streamID; + self->currentLiveStreamInfo = info; + + self->setCurrentStreamingStatus(OneSevenLiveStreamingStatus::Live); + + self->wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + obs_log(LOG_INFO, + "Live stream created successfully (Async). LiveStreamID: %s", + self->currentLiveStreamID.c_str()); + + emit self->createRtmpFinished(true, QString()); + } else { + obs_log(LOG_ERROR, "Failed to create stream (Async). Error: %s", + errorMsg.toStdString().c_str()); + emit self->errorOccurred(errorMsg, "createLiveStream"); + emit self->createRtmpFinished(false, errorMsg); + } + }, + Qt::QueuedConnection); + } + }); +} + +bool OneSevenLiveStreamManager::startStream() { + obs_log(LOG_INFO, "Starting streaming"); + + // Configure streaming service (RTMP or WHIP) + configureStreamingService(currentStreamResponse); + + // Start live stream via API + if (!apiWrapper->StartStream(currentStreamResponse.liveStreamID.toStdString(), currentUserID)) { + QString errorMsg = apiWrapper->getLastErrorMessage(); + obs_log(LOG_ERROR, + "Failed to start stream. LiveStreamID: %s, UserID: %s, Error: %s, Timestamp: %lld", + currentStreamResponse.liveStreamID.toStdString().c_str(), currentUserID.c_str(), + errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str(), + QDateTime::currentMSecsSinceEpoch()); + emit errorOccurred(errorMsg, "startStream"); + return false; + } + + // Enable archive if requested + if (currentStreamRequest.archiveConfig.autoRecording) { + enableStreamArchive(currentStreamResponse.liveStreamID.toStdString(), true); + } + + // Update status + setCurrentStreamingStatus(OneSevenLiveStreamingStatus::Streaming); + + // Broadcast Ably chat connected when streaming starts + wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + obs_log(LOG_INFO, "Streaming started successfully"); + return true; +} + +void OneSevenLiveStreamManager::startStreamAsync() { + obs_log(LOG_INFO, "Starting streaming (Async)"); + + configureStreamingService(currentStreamResponse); + + std::string lid = currentStreamResponse.liveStreamID.toStdString(); + std::string uid = currentUserID; + bool autoRecord = currentStreamRequest.archiveConfig.autoRecording; + + auto* api = this->apiWrapper; + QPointer self = this; + + ScheduleOBSTask([self, lid, uid, autoRecord, api]() { + if (!self) + return; + + bool success = false; + QString errorMsg; + + if (api) { + success = api->StartStream(lid, uid); + if (!success) { + errorMsg = api->getLastErrorMessage(); + } else if (autoRecord) { + if (!api->EnableStreamArchive(lid, 1)) { + QString archiveError = api->getLastErrorMessage(); + obs_log(LOG_ERROR, "Failed to enable archive (Async). Error: %s", + archiveError.toStdString().c_str()); + success = false; + errorMsg = archiveError; + } + } + } else { + errorMsg = "API Wrapper not initialized"; + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, success, errorMsg]() { + if (success) { + self->setCurrentStreamingStatus(OneSevenLiveStreamingStatus::Streaming); + + self->wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", "connected"}}); + + obs_log(LOG_INFO, "Streaming started successfully (Async)"); + emit self->startStreamFinished(true, QString()); + } else { + obs_log(LOG_ERROR, "Failed to start stream (Async). Error: %s", + errorMsg.toStdString().c_str()); + emit self->errorOccurred(errorMsg, "startStream"); + emit self->startStreamFinished(false, errorMsg); + } + }, + Qt::QueuedConnection); + } + }); +} + +void OneSevenLiveStreamManager::changeEventAsync(const OneSevenLiveChangeEventRequest& request) { + obs_log(LOG_INFO, "Changing event (Async) to: %lld", request.eventID); + + auto* api = this->apiWrapper; + QPointer self = this; + + ScheduleOBSTask([self, request, api]() { + if (!self) + return; + + bool success = false; + QString errorMsg; + + if (api) { + success = api->ChangeEvent(request); + if (!success) { + errorMsg = api->getLastErrorMessage(); + } + } else { + errorMsg = "API Wrapper not initialized"; + } + + if (self) { + QMetaObject::invokeMethod( + self, + [self, success, errorMsg, request]() { + if (success) { + obs_log(LOG_INFO, "Successfully changed event (Async) to: %lld", + request.eventID); + emit self->changeEventFinished(true, QString()); + } else { + obs_log(LOG_ERROR, "Failed to change event (Async) to: %lld, error: %s", + request.eventID, errorMsg.toStdString().c_str()); + emit self->changeEventFinished(false, errorMsg); + } + }, + Qt::QueuedConnection); + } + }); +} + +bool OneSevenLiveStreamManager::stopStream(bool isAutoClose) { + obs_log(LOG_INFO, "Stopping streaming"); + + // Stop OBS streaming first + stopOBSStreaming(); + + QString endReason = isAutoClose ? "autoClose" : "normalEnd"; + + // Send close live stream request + OneSevenLiveCloseLiveRequest request; + request.reason = endReason; + request.userID = QString::fromStdString(currentUserID); + + if (!apiWrapper->StopStream(currentLiveStreamID, request)) { + QString errorMsg = apiWrapper->getLastErrorMessage(); + obs_log(LOG_ERROR, "Failed to stop stream. LiveStreamID: %s, UserID: %s, Reason: %s", + currentLiveStreamID.c_str(), currentUserID.c_str(), + endReason.toStdString().c_str()); + emit errorOccurred(errorMsg, "stopStreaming"); + } else { + obs_log(LOG_INFO, + "Successfully stopped stream. LiveStreamID: %s, UserID: %s, Reason: %s, " + "IsAutoClose: %s", + currentLiveStreamID.c_str(), currentUserID.c_str(), endReason.toStdString().c_str(), + isAutoClose ? "true" : "false"); + } + + // Clear streaming configuration + clearStreamingConfiguration(); + + // Update status + setCurrentStreamingStatus(OneSevenLiveStreamingStatus::NotStarted); + + // Broadcast Ably chat break when streaming stops + wsBroadcast(QString::fromUtf8(ws::EventAblyChatConnected), nlohmann::json{{"status", "break"}}); + + // Clear current stream info + currentLiveStreamID.clear(); + currentUserID.clear(); + currentStreamRequest = OneSevenLiveRtmpRequest{}; + currentStreamResponse = OneSevenLiveRtmpResponse{}; + currentLiveStreamInfo = OneSevenLiveStreamInfo{}; + + obs_log(LOG_INFO, "Streaming stopped successfully"); + return true; +} + +void OneSevenLiveStreamManager::onStatusTimer() { + wsBroadcast( + QString::fromUtf8(ws::EventAblyChatConnected), + nlohmann::json{{"status", currentStreamingStatus == OneSevenLiveStreamingStatus::NotStarted + ? "break" + : "connected"}}); +} + +void OneSevenLiveStreamManager::startOBSStreaming() { + obs_video_info vinfo{}; + if (obs_get_video_info(&vinfo)) { + obs_log(LOG_INFO, "OBS video: base=%ux%u output=%ux%u fps=%u/%u colorspace=%d range=%d", + vinfo.base_width, vinfo.base_height, vinfo.output_width, vinfo.output_height, + vinfo.fps_num, vinfo.fps_den, (int) vinfo.colorspace, (int) vinfo.range); + } + obs_audio_info ainfo{}; + if (obs_get_audio_info(&ainfo)) { + obs_log(LOG_INFO, "OBS audio: rate=%u speakers=%d", ainfo.samples_per_sec, + (int) ainfo.speakers); + } + + obs_service_t* svc = obs_frontend_get_streaming_service(); + if (svc) { + const char* stype = obs_service_get_type(svc); + const char* proto = obs_service_get_protocol(svc); + ObsDataPtr sset{obs_service_get_settings(svc)}; + const char* server = sset ? obs_data_get_string(sset.get(), "server") : nullptr; + const char* key = sset ? obs_data_get_string(sset.get(), "key") : nullptr; + const char* token = sset ? obs_data_get_string(sset.get(), "bearer_token") : nullptr; + const char** svc_vcodecs = obs_service_get_supported_video_codecs(svc); + const char** svc_acodecs = obs_service_get_supported_audio_codecs(svc); + int max_v_bitrate = 0, max_a_bitrate = 0; + obs_service_get_max_bitrate(svc, &max_v_bitrate, &max_a_bitrate); + obs_log(LOG_INFO, "OBS service: type=%s proto=%s server=%s key_len=%zu token_len=%zu", + stype ? stype : "", proto ? proto : "", server ? server : "", key ? strlen(key) : 0, + token ? strlen(token) : 0); + if (svc_vcodecs) { + std::string vlist; + for (size_t i = 0; svc_vcodecs[i]; ++i) { + if (!vlist.empty()) + vlist += ","; + vlist += svc_vcodecs[i]; + } + obs_log(LOG_INFO, "OBS service supported video codecs: %s", vlist.c_str()); + } + if (svc_acodecs) { + std::string alist; + for (size_t i = 0; svc_acodecs[i]; ++i) { + if (!alist.empty()) + alist += ","; + alist += svc_acodecs[i]; + } + obs_log(LOG_INFO, "OBS service supported audio codecs: %s", alist.c_str()); + } + obs_log(LOG_INFO, "OBS service max bitrate: video=%d audio=%d", max_v_bitrate, + max_a_bitrate); + } + + obs_frontend_streaming_start(); + if (!m_streamLogTimer) { + m_streamLogTimer = new QTimer(this); + m_streamLogTimer->setSingleShot(true); + connect(m_streamLogTimer, &QTimer::timeout, this, + &OneSevenLiveStreamManager::logCurrentObsOutputInfo); + } + m_streamLogTimer->start(200); +} + +void OneSevenLiveStreamManager::logCurrentObsOutputInfo() { + obs_output_t* out = obs_frontend_get_streaming_output(); + if (!out) { + return; + } + const char* oid = obs_output_get_id(out); + uint32_t ow = obs_output_get_width(out); + uint32_t oh = obs_output_get_height(out); + obs_encoder_t* venc = obs_output_get_video_encoder(out); + obs_encoder_t* aenc = obs_output_get_audio_encoder(out, 0); + const char* v_id = venc ? obs_encoder_get_id(venc) : nullptr; + const char* v_codec = venc ? obs_encoder_get_codec(venc) : nullptr; + ObsDataPtr vset{venc ? obs_encoder_get_settings(venc) : nullptr}; + int v_bitrate = vset ? (int) obs_data_get_int(vset.get(), "bitrate") : 0; + uint32_t v_scaled_w = venc ? obs_encoder_get_width(venc) : 0; + uint32_t v_scaled_h = venc ? obs_encoder_get_height(venc) : 0; + uint32_t v_fps_div = venc ? obs_encoder_get_frame_rate_divisor(venc) : 1; + const char* a_id = aenc ? obs_encoder_get_id(aenc) : nullptr; + const char* a_codec = aenc ? obs_encoder_get_codec(aenc) : nullptr; + ObsDataPtr aset{aenc ? obs_encoder_get_settings(aenc) : nullptr}; + int a_bitrate = aset ? (int) obs_data_get_int(aset.get(), "bitrate") : 0; + uint32_t a_rate = aenc ? obs_encoder_get_sample_rate(aenc) : 0; + size_t a_mixer = aenc ? obs_encoder_get_mixer_index(aenc) : 0; + const char* out_v_supported = obs_output_get_supported_video_codecs(out); + const char* out_a_supported = obs_output_get_supported_audio_codecs(out); + obs_log(LOG_INFO, + "OBS output: id=%s size=%ux%u video_encoder=%s codec=%s bitrate=%d scaled=%ux%u " + "fps_div=%u audio_encoder=%s codec=%s bitrate=%d rate=%u mixer=%zu", + oid ? oid : "", ow, oh, v_id ? v_id : "", v_codec ? v_codec : "", v_bitrate, v_scaled_w, + v_scaled_h, v_fps_div, a_id ? a_id : "", a_codec ? a_codec : "", a_bitrate, a_rate, + a_mixer); + obs_log(LOG_INFO, "OBS output supported codecs: video=%s audio=%s", + out_v_supported ? out_v_supported : "", out_a_supported ? out_a_supported : ""); +} + +void OneSevenLiveStreamManager::stopOBSStreaming() { + obs_log(LOG_INFO, "Stopping OBS streaming"); + auto& core = OneSevenLiveCoreManager::getInstance(); + if (QThread::currentThread() != core.thread()) { + QMetaObject::invokeMethod( + &core, [this]() { this->stopOBSStreaming(); }, Qt::BlockingQueuedConnection); + return; + } + if (!obs_frontend_streaming_active()) { + obs_log(LOG_INFO, "OBS streaming is not active"); + return; + } + + if (m_streamLogTimer) { + m_streamLogTimer->stop(); + } + + obs_frontend_streaming_stop(); + + // Removed busy-wait loop. We rely on OBS_FRONTEND_EVENT_STREAMING_STOPPED event. + obs_log(LOG_INFO, "OBS streaming stop requested"); +} + +bool OneSevenLiveStreamManager::isOBSStreaming() const { + return obs_frontend_streaming_active(); +} + +bool OneSevenLiveStreamManager::saveStreamConfiguration(const OneSevenLiveStreamInfo& streamInfo) { + obs_log(LOG_INFO, "Saving stream configuration"); + + if (!configManager->saveLiveConfig(streamInfo)) { + obs_log(LOG_ERROR, "Failed to save stream info"); + emit errorOccurred("Failed to save stream configuration", "saveStreamConfiguration"); + return false; + } + + emit streamConfigurationSaved(); + obs_log(LOG_INFO, "Stream configuration saved successfully"); + return true; +} + +OneSevenLiveStreamingStatus OneSevenLiveStreamManager::getCurrentStreamingStatus() const { + return currentStreamingStatus; +} + +void OneSevenLiveStreamManager::setCurrentStreamingStatus(OneSevenLiveStreamingStatus status) { + if (currentStreamingStatus != status) { + currentStreamingStatus = status; + emit streamStatusChanged(status); + } +} + +std::string OneSevenLiveStreamManager::getCurrentLiveStreamID() const { + return currentLiveStreamID; +} + +std::string OneSevenLiveStreamManager::getCurrentUserID() const { + return currentUserID; +} + +qint64 OneSevenLiveStreamManager::getRoomID() const { + return configManager->getRoomID(); +} + +void OneSevenLiveStreamManager::saveStreamingSettings(const std::string& liveStreamID, + const std::string& streamUrl, + const std::string& streamKey) { + obs_log(LOG_INFO, "Saving RTMP streaming settings for stream: %s", liveStreamID.c_str()); + + // Get OBS service + obs_service_t* service = obs_service_create("rtmp_custom", "default_service", NULL, NULL); + if (!service) { + obs_log(LOG_ERROR, "Failed to create OBS service"); + return; + } + + // Set streaming URL and key + ObsDataPtr settings{obs_service_get_settings(service)}; + obs_data_set_string(settings.get(), "server", streamUrl.c_str()); + obs_data_set_string(settings.get(), "key", streamKey.c_str()); + + // Apply settings + obs_service_update(service, settings.get()); + settings.reset(); + + obs_frontend_set_streaming_service(service); + obs_frontend_save_streaming_service(); + + // Release resources + obs_service_release(service); + + // Store in config manager + configManager->setStreamingInfo(liveStreamID, streamUrl, streamKey); + configManager->setWhipMode(false); + + obs_log(LOG_INFO, "RTMP streaming settings saved successfully"); +} + +void OneSevenLiveStreamManager::saveWhipStreamingSettings(const std::string& liveStreamID, + const std::string& whipServer, + const std::string& whipToken) { + obs_log(LOG_INFO, "Saving WHIP streaming settings for stream: %s", liveStreamID.c_str()); + + // Set WHIP server and token + ObsDataPtr settings{obs_data_create()}; + obs_data_set_string(settings.get(), "type", "whip_custom"); + obs_data_set_string(settings.get(), "service", "WHIP"); + obs_data_set_string(settings.get(), "server", whipServer.c_str()); + obs_data_set_string(settings.get(), "bearer_token", whipToken.c_str()); + + // Get or create WHIP service + obs_service_t* service = + obs_service_create("whip_custom", "whip_service", settings.get(), NULL); + if (!service) { + obs_log(LOG_ERROR, "Failed to create WHIP service"); + settings.reset(); + return; + } + + // Set as current streaming service + obs_frontend_set_streaming_service(service); + + obs_service_release(service); + settings.reset(); + + obs_frontend_save_streaming_service(); + + // Store in config manager + configManager->setWhipStreamingInfo(liveStreamID, whipServer, whipToken); + configManager->setWhipMode(true); + + obs_log(LOG_INFO, "WHIP streaming settings saved successfully"); +} + +void OneSevenLiveStreamManager::configureStreamingService( + const OneSevenLiveRtmpResponse& response) { + obs_log(LOG_INFO, "Configuring streaming service"); + + // Check if WHIP information is available + bool hasWhipInfo = !response.whipInfo.server.isEmpty() && !response.whipInfo.token.isEmpty(); + + if (hasWhipInfo) { + // WHIP mode + obs_log(LOG_INFO, "Using WHIP streaming mode"); + saveWhipStreamingSettings(response.liveStreamID.toStdString(), + response.whipInfo.server.toStdString(), + response.whipInfo.token.toStdString()); + } else { + // RTMP mode + obs_log(LOG_INFO, "Using RTMP streaming mode"); + + QString streamUrl; + QString streamKey; + + // Parse RTMP URL using regex + QRegularExpression re("(^.+://[^/]+/[^/]+)/(.+)$"); + QRegularExpressionMatch match = re.match(response.rtmpURL); + if (match.hasMatch()) { + streamUrl = match.captured(1); + streamKey = match.captured(2); + } else { + obs_log(LOG_ERROR, "Failed to parse stream URL"); + emit errorOccurred("Failed to parse stream URL", "configureStreamingService"); + return; + } + + saveStreamingSettings(response.liveStreamID.toStdString(), streamUrl.toStdString(), + streamKey.toStdString()); + } +} + +bool OneSevenLiveStreamManager::enableStreamArchive(const std::string& liveStreamID, bool enable) { + obs_log(LOG_INFO, "%s stream archive for stream: %s", enable ? "Enabling" : "Disabling", + liveStreamID.c_str()); + + if (!apiWrapper->EnableStreamArchive(liveStreamID, enable ? 1 : 0)) { + QString errorMsg = apiWrapper->getLastErrorMessage(); + obs_log(LOG_ERROR, "Failed to %s archive. LiveStreamID: %s, Error: %s", + enable ? "enable" : "disable", liveStreamID.c_str(), + errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str()); + emit errorOccurred(errorMsg, "enableStreamArchive"); + return false; + } + + obs_log(LOG_INFO, "Stream archive %s successfully", enable ? "enabled" : "disabled"); + return true; +} + +void OneSevenLiveStreamManager::clearStreamingConfiguration() { + obs_log(LOG_INFO, "Clearing streaming configuration"); + + // Clear streaming configuration based on current mode + if (configManager->isWhipMode()) { + configManager->clearWhipStreamingInfo(); + } else { + configManager->clearStreamingInfo(); + } + configManager->setWhipMode(false); + + obs_log(LOG_INFO, "Streaming configuration cleared"); +} + +void OneSevenLiveStreamManager::configureStreamingSettings( + const OneSevenLiveRtmpResponse& response) { + obs_log(LOG_INFO, "Configuring streaming settings"); + + // Store the current stream response for later use + currentStreamResponse = response; + + // Check if WHIP information is available + bool hasWhipInfo = !response.whipInfo.server.isEmpty() && !response.whipInfo.token.isEmpty(); + + if (hasWhipInfo) { + // WHIP mode + obs_log(LOG_INFO, "Using WHIP streaming mode"); + saveWhipStreamingSettings(response.liveStreamID.toStdString(), + response.whipInfo.server.toStdString(), + response.whipInfo.token.toStdString()); + } else { + // RTMP mode + obs_log(LOG_INFO, "Using RTMP streaming mode"); + + QString streamUrl; + QString streamKey; + + // Parse RTMP URL using regex + QRegularExpression re("(^.+://[^/]+/[^/]+)/(.+)$"); + QRegularExpressionMatch match = re.match(response.rtmpURL); + if (match.hasMatch()) { + streamUrl = match.captured(1); + streamKey = match.captured(2); + } else { + obs_log(LOG_ERROR, "Failed to parse stream URL"); + emit errorOccurred("Failed to parse stream URL", "configureStreamingSettings"); + return; + } + + saveStreamingSettings(response.liveStreamID.toStdString(), streamUrl.toStdString(), + streamKey.toStdString()); + } +} + +void OneSevenLiveStreamManager::handleObsStreamStopped(int code, const QString& lastError) { + // This is called from OBS callback thread, so we need to invoke on main thread + QMetaObject::invokeMethod( + this, [this, code, lastError]() { emit obsStreamStopped(code, lastError); }, + Qt::QueuedConnection); +} + +const OneSevenLiveRtmpResponse& OneSevenLiveStreamManager::getCurrentStreamResponse() const { + return currentStreamResponse; +} + +const OneSevenLiveRtmpRequest& OneSevenLiveStreamManager::getCurrentStreamRequest() const { + return currentStreamRequest; +} + +const OneSevenLiveStreamInfo& OneSevenLiveStreamManager::getCurrentLiveStreamInfo() const { + return currentLiveStreamInfo; +} + +bool OneSevenLiveStreamManager::hasActiveLiveStream() const { + return !currentLiveStreamID.empty() && + currentStreamingStatus != OneSevenLiveStreamingStatus::NotStarted; +} + +void OneSevenLiveStreamManager::loadRoomInfo() { + if (roomInfoLoading) + return; + + roomInfoLoading = true; + + auto* api = this->apiWrapper; + auto* cm = this->configManager; + qint64 rid = currentRoomID; + QPointer self = this; + + ScheduleOBSTask([self, api, cm, rid]() { + if (!self) + return; + + OneSevenLiveRoomInfo localRoomInfo; + OneSevenLiveConfigStreamer localConfigStreamer; + OneSevenLiveUserInfo localUserInfo; + OneSevenLiveArmySubscriptionLevels localLevels; + + OneSevenLiveLoadRoomInfoWorker worker(api, cm); + worker.setDataStructures(&localRoomInfo, &localConfigStreamer, &localUserInfo, + &localLevels); + + OneSevenLiveLoadRoomInfoWorker::LoadResult result = + worker.loadRoomInfo(static_cast(rid)); + + if (self) { + QMetaObject::invokeMethod( + self, + [self, result, localRoomInfo, localConfigStreamer, localUserInfo, localLevels]() { + self->roomInfo = localRoomInfo; + self->configStreamer = localConfigStreamer; + self->userInfo = localUserInfo; + self->levels = localLevels; + self->roomInfoLoading = false; + + emit self->roomInfoLoaded(result); + }, + Qt::QueuedConnection); + } + }); +} + +void OneSevenLiveStreamManager::wsBroadcast(const QString& type, const nlohmann::json& payload) { + auto& core = OneSevenLiveCoreManager::getInstance(); + if (auto* ws = core.getWebsocketServer()) { + WsMessage msg; + msg.type = type.toStdString(); + msg.payload = payload; + ws->broadcastMessage(msg.dump()); + } +} diff --git a/src/17live/streaming/OneSevenLiveStreamManager.hpp b/src/17live/streaming/OneSevenLiveStreamManager.hpp new file mode 100644 index 0000000..cc63073 --- /dev/null +++ b/src/17live/streaming/OneSevenLiveStreamManager.hpp @@ -0,0 +1,290 @@ +#pragma once + +#include +#include +#include +#include + +#include "OneSevenLiveLoadRoomInfoWorker.hpp" +#include "api/OneSevenLiveModels.hpp" + +// Forward declarations +class OneSevenLiveApiWrappers; +class OneSevenLiveConfigManager; + +/** + * @brief OneSevenLiveStreamManager class manages 17Live stream configuration and control + * + * This class is responsible for managing all aspects of 17Live streaming including: + * - Stream configuration and settings + * - Stream creation and management + * - Playback control (start/stop) + * - Integration with OBS streaming service + */ +class OneSevenLiveStreamManager : public QObject { + Q_OBJECT + + public: + /** + * @brief Constructor + * @param apiWrapper API wrapper instance for making HTTP requests + * @param configManager Configuration manager for storing settings + * @param parent Parent QObject + */ + explicit OneSevenLiveStreamManager(OneSevenLiveApiWrappers* apiWrapper, + OneSevenLiveConfigManager* configManager, + QObject* parent = nullptr); + ~OneSevenLiveStreamManager(); + + /** + * @brief Create a new live stream + * @param request RTMP request containing stream configuration + * @return bool True if stream creation was successful + */ + bool createRtmp(const OneSevenLiveRtmpRequest& request); + + /** + * @brief Create a new live stream asynchronously + * @param request RTMP request containing stream configuration + */ + void createRtmpAsync(const OneSevenLiveRtmpRequest& request); + + /** + * @brief Start streaming with the given configuration + * @return bool True if streaming started successfully + */ + bool startStream(); + + /** + * @brief Start streaming with the given configuration asynchronously + */ + void startStreamAsync(); + + /** + * @brief Change the current event asynchronously + * @param request Change event request + */ + void changeEventAsync(const OneSevenLiveChangeEventRequest& request); + + /** + * @brief Stop the current stream + * @param isAutoClose Whether this is an automatic close + * @return bool True if stream was stopped successfully + */ + bool stopStream(bool isAutoClose = false); + + /** + * @brief Start live stream with the given response + * @param liveStreamID Live stream ID + * @param userID User ID + * @param autoRecording Whether to enable automatic recording + * @return bool True if stream was started successfully + */ + + /** + * @brief Configure streaming settings based on response (WHIP or RTMP) + * @param response RTMP response containing stream credentials + */ + void configureStreamingSettings(const OneSevenLiveRtmpResponse& response); + + /** + * @brief Get current stream response + * @return OneSevenLiveRtmpResponse Current stream response + */ + const OneSevenLiveRtmpResponse& getCurrentStreamResponse() const; + + /** + * @brief Get current stream request used for creation + * @return OneSevenLiveRtmpRequest Current stream request + */ + const OneSevenLiveRtmpRequest& getCurrentStreamRequest() const; + + /** + * @brief Get current live stream info snapshot + * @return OneSevenLiveStreamInfo Current live stream info + */ + const OneSevenLiveStreamInfo& getCurrentLiveStreamInfo() const; + + /** + * @brief Check if there is an active live stream + * @return bool True if a live stream is active + */ + bool hasActiveLiveStream() const; + + void startOBSStreaming(); + + /** + * @brief Stop OBS streaming (frontend control) + */ + void stopOBSStreaming(); + + /** + * @brief Check if OBS is currently streaming + * @return bool True if OBS is streaming + */ + bool isOBSStreaming() const; + + /** + * @brief Save stream configuration + * @param streamInfo Stream information to save + * @return bool True if configuration was saved successfully + */ + bool saveStreamConfiguration(const OneSevenLiveStreamInfo& streamInfo); + + void loadRoomInfo(); + + void handleObsStreamStopped(int code, const QString& lastError); + + const OneSevenLiveRoomInfo& getRoomInfo() const { + return roomInfo; + } + + const OneSevenLiveConfigStreamer& getConfigStreamer() const { + return configStreamer; + } + + const OneSevenLiveUserInfo& getUserInfo() const { + return userInfo; + } + + const OneSevenLiveArmySubscriptionLevels& getArmyLevels() const { + return levels; + } + + bool isRoomInfoLoading() const { + return roomInfoLoading; + } + + bool fetchRtmpByProvider(const std::string& provider, OneSevenLiveRtmpResponse& response); + QString getLastErrorMessage() const; + bool startStreamWithWeb(); + void startStreamWithWebAsync(); + + /** + * @brief Get current streaming status + * @return OneSevenLiveStreamingStatus Current status + */ + OneSevenLiveStreamingStatus getCurrentStreamingStatus() const; + + /** + * @brief Set current streaming status + * @param status New streaming status + */ + void setCurrentStreamingStatus(OneSevenLiveStreamingStatus status); + + /** + * @brief Get current live stream ID + * @return std::string Current live stream ID + */ + std::string getCurrentLiveStreamID() const; + + /** + * @brief Get current user ID + * @return std::string Current user ID + */ + std::string getCurrentUserID() const; + + /** + * @brief Get current room ID + * @return qint64 Current room ID + */ + qint64 getRoomID() const; + + /** + * @brief Save RTMP streaming settings to OBS + * @param liveStreamID Live stream ID + * @param streamUrl RTMP server URL + * @param streamKey Stream key + */ + void saveStreamingSettings(const std::string& liveStreamID, const std::string& streamUrl, + const std::string& streamKey); + + /** + * @brief Save WHIP streaming settings to OBS + * @param liveStreamID Live stream ID + * @param whipServer WHIP server URL + * @param whipToken WHIP authentication token + */ + void saveWhipStreamingSettings(const std::string& liveStreamID, const std::string& whipServer, + const std::string& whipToken); + + /** + * @brief Clear streaming configuration from OBS + */ + public: + void clearStreamingConfiguration(); + + signals: + /** + * @brief Emitted when stream status changes + * @param status New streaming status + */ + void streamStatusChanged(OneSevenLiveStreamingStatus status); + + /** + * @brief Emitted when stream configuration is saved + */ + void streamConfigurationSaved(); + + /** + * @brief Emitted when an error occurs + * @param errorMessage Error message + * @param operation Operation that failed + */ + void errorOccurred(const QString& errorMessage, const QString& operation); + + /** + * @brief Emitted when OBS streaming stops (e.g. error or manual stop) + */ + void obsStreamStopped(int code, const QString& lastError); + + void createRtmpFinished(bool success, const QString& error); + void startStreamFinished(bool success, const QString& error); + void changeEventFinished(bool success, const QString& error); + void webStreamSettingsLoaded(bool success); + + void roomInfoLoaded(const OneSevenLiveLoadRoomInfoWorker::LoadResult& result); + + private: + /** + * @brief Configure OBS streaming service + * @param response RTMP response containing credentials + */ + void configureStreamingService(const OneSevenLiveRtmpResponse& response); + + /** + * @brief Enable stream archive + * @param liveStreamID Live stream ID + * @param enable Whether to enable archive + * @return bool True if archive was enabled successfully + */ + bool enableStreamArchive(const std::string& liveStreamID, bool enable); + + // Member variables + OneSevenLiveApiWrappers* apiWrapper = nullptr; + OneSevenLiveConfigManager* configManager = nullptr; + + OneSevenLiveStreamingStatus currentStreamingStatus = OneSevenLiveStreamingStatus::NotStarted; + + std::string currentLiveStreamID; + std::string currentUserID; + qint64 currentRoomID = 0; + + OneSevenLiveRtmpResponse currentStreamResponse; // Store current stream response + OneSevenLiveRtmpRequest currentStreamRequest; // Store current stream request + OneSevenLiveStreamInfo currentLiveStreamInfo; // Snapshot info for current live + + // Loaded data for room info + OneSevenLiveRoomInfo roomInfo; + OneSevenLiveConfigStreamer configStreamer; + OneSevenLiveUserInfo userInfo; + OneSevenLiveArmySubscriptionLevels levels; + bool roomInfoLoading = false; + + QTimer* m_statusTimer{nullptr}; + void onStatusTimer(); + void logCurrentObsOutputInfo(); + QTimer* m_streamLogTimer{nullptr}; + + void wsBroadcast(const QString& type, const nlohmann::json& payload); +}; diff --git a/src/17live/OneSevenLiveStreamingDock.cpp b/src/17live/streaming/OneSevenLiveStreamingDock.cpp similarity index 68% rename from src/17live/OneSevenLiveStreamingDock.cpp rename to src/17live/streaming/OneSevenLiveStreamingDock.cpp index f9cea76..58a7698 100644 --- a/src/17live/OneSevenLiveStreamingDock.cpp +++ b/src/17live/streaming/OneSevenLiveStreamingDock.cpp @@ -3,10 +3,14 @@ #include #include +#include +#include #include +#include #include #include #include +#include #include #include #include @@ -17,31 +21,151 @@ #include #include "OneSevenLiveConfigManager.hpp" +#include "OneSevenLiveCoreManager.hpp" #include "OneSevenLiveCustomEventDialog.hpp" +#include "OneSevenLiveLoadRoomInfoWorker.hpp" +#include "OneSevenLiveStreamingDock.hpp" #include "api/OneSevenLiveApiWrappers.hpp" #include "moc_OneSevenLiveStreamingDock.cpp" +#include "multi-rtmp/OneSevenLiveMultiRtmpManager.hpp" #include "plugin-support.h" +#include "streaming/OneSevenLiveStreamManager.hpp" #include "utility/Common.hpp" #include "utility/Meta.hpp" OneSevenLiveStreamingDock::OneSevenLiveStreamingDock(QWidget *parent, - OneSevenLiveApiWrappers *apiWrapper_, + OneSevenLiveStreamManager *streamManager_, + OneSevenLiveApiWrappers *apiWrappers_, OneSevenLiveConfigManager *configManager_) : QDockWidget(obs_module_text("Live.Settings"), parent), - apiWrapper(apiWrapper_), - configManager(configManager_) { + streamManager(streamManager_), + apiWrapper(apiWrappers_), + configManager(configManager_), + eventCooldownTimer(new QTimer(this)), + eventCooldownRemaining(0) { // Initialize category cooldown timer - eventCooldownTimer = new QTimer(this); eventCooldownTimer->setSingleShot(false); eventCooldownTimer->setInterval(1000); // 1 second interval connect(eventCooldownTimer, &QTimer::timeout, this, &OneSevenLiveStreamingDock::onEventCooldownTimeout); + connect(streamManager, &OneSevenLiveStreamManager::streamStatusChanged, this, + &OneSevenLiveStreamingDock::updateLiveStatus); + + // Monitor OBS output signals for disconnection + // We cannot directly connect to obs signals here easily as they are C callbacks. + // Instead, we rely on StreamManager to monitor OBS signals or use a timer/status check. + // However, StreamManager already has some status monitoring. + // Let's add a signal from StreamManager when OBS stream stops unexpectedly. + + connect(streamManager, &OneSevenLiveStreamManager::obsStreamStopped, this, + [this](int code, const QString &lastError) { + obs_log(LOG_WARNING, "OBS stream stopped unexpectedly with code %d: %s", code, + lastError.toStdString().c_str()); + // If this was an unexpected stop (network error etc), we might want to reflect that + // in UI For now, just ensure our internal status is updated if it wasn't already + if (streamManager->getCurrentStreamingStatus() == + OneSevenLiveStreamingStatus::Streaming) { + // If we were streaming, but OBS stopped, we should probably consider it as + // stopped or trying to reconnect? OBS has its own reconnection logic. If OBS + // completely gives up (e.g. after max retries), it stops. We should sync our + // status to NotStarted in that case. + streamManager->stopStream(false); + } + }); + + connect(streamManager, &OneSevenLiveStreamManager::createRtmpFinished, this, + [this](bool success, const QString &error) { + createLiveButton->setEnabled(true); + + if (success) { + startEventCooldown(); + startLive(); + } else { + QString msg = error; + if (msg.isEmpty()) + msg = obs_module_text("Live.Create.Failed"); + QMessageBox::warning(this, obs_module_text("Live.Create.Title"), msg); + } + }); + + connect(streamManager, &OneSevenLiveStreamManager::startStreamFinished, this, + [this](bool success, const QString &error) { + createLiveButton->setEnabled(true); + + if (success) { + // Ask whether to start streaming simultaneously + QMessageBox msgBox(this); + msgBox.setWindowTitle(obs_module_text("Live.Settings.StartStreaming")); + msgBox.setText(obs_module_text("Live.Settings.StartStreaming.Tip")); + + QPushButton *yesButton = msgBox.addButton(obs_module_text("Live.Settings.Yes"), + QMessageBox::YesRole); + msgBox.addButton(obs_module_text("Live.Settings.No"), QMessageBox::NoRole); + msgBox.setDefaultButton(yesButton); + + msgBox.exec(); + if (msgBox.clickedButton() == yesButton) { + streamManager->startOBSStreaming(); + } + } else { + QString msg = error; + if (msg.isEmpty()) + msg = obs_module_text("Live.Start.Failed"); + QMessageBox::warning(this, obs_module_text("Live.Settings.Error"), msg); + } + }); + + connect(streamManager, &OneSevenLiveStreamManager::changeEventFinished, this, + [this](bool success, const QString &error) { + if (success) { + startEventCooldown(); + } else { + obs_log(LOG_ERROR, "Failed to change event: %s", error.toStdString().c_str()); + QMessageBox::warning(this, obs_module_text("Live.Common.Notice"), + obs_module_text("Live.ChangeEvent.Failed")); + + if (!isEventInCooldown()) { + eventCombo->setEnabled(true); + if (previousEventIndex >= 0 && previousEventIndex < eventCombo->count()) { + eventCombo->blockSignals(true); + eventCombo->setCurrentIndex(previousEventIndex); + eventCombo->blockSignals(false); + } + } + } + }); + setupUi(); createConnections(); + + QTimer::singleShot(0, this, [this]() { loadRoomInfo(); }); } -OneSevenLiveStreamingDock::~OneSevenLiveStreamingDock() = default; +OneSevenLiveStreamingDock::~OneSevenLiveStreamingDock() { + if (streamManager) { + disconnect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, nullptr); + disconnect(streamManager, &OneSevenLiveStreamManager::streamStatusChanged, this, nullptr); + } + + // Disconnect all QComboBox signals to prevent crashes during destruction + if (eventCombo) { + disconnect(eventCombo, nullptr, this, nullptr); + } + if (categoryCombo) { + disconnect(categoryCombo, nullptr, this, nullptr); + } + if (requiredArmyRankCombo) { + disconnect(requiredArmyRankCombo, nullptr, this, nullptr); + } + if (clipIdentityCombo) { + disconnect(clipIdentityCombo, nullptr, this, nullptr); + } + + if (eventCooldownTimer->isActive()) { + eventCooldownTimer->stop(); + } +} void OneSevenLiveStreamingDock::setupUi() { QWidget *container = new QWidget(this); @@ -118,7 +242,7 @@ void OneSevenLiveStreamingDock::setupUi() { formLayout->addRow(titleLabel, titleEdit); // Category selection - categoryCombo = new QComboBox(); + categoryCombo = new QComboBox(this); // Set parent to ensure proper cleanup QLabel *categoryLabel = new QLabel(); categoryLabel->setText( QString("*%1") @@ -171,7 +295,7 @@ void OneSevenLiveStreamingDock::setupUi() { eventContainer->addWidget(eventLabel); // Dropdown box - eventCombo = new QComboBox(); + eventCombo = new QComboBox(this); // Set parent to ensure proper cleanup eventContainer->addWidget(eventCombo); // Create hint label and align right @@ -247,7 +371,7 @@ void OneSevenLiveStreamingDock::setupUi() { QLabel *userConditionLabel = new QLabel(obs_module_text("Live.Settings.UserCondition")); userConditionLayout->addWidget(userConditionLabel); - requiredArmyRankCombo = new QComboBox(); + requiredArmyRankCombo = new QComboBox(this); // Set parent to ensure proper cleanup requiredArmyRankCombo->setEditable(false); userConditionLayout->addWidget(requiredArmyRankCombo); @@ -383,7 +507,7 @@ void OneSevenLiveStreamingDock::setupUi() { clipLayout->addWidget(clipTip); // Clip identity - clipIdentityCombo = new QComboBox(); + clipIdentityCombo = new QComboBox(this); // Set parent to ensure proper cleanup QList clipIdentityList; getMetaValueLabelList("ClipPermissions", clipIdentityList); for (const auto &item : clipIdentityList) { @@ -445,96 +569,58 @@ void OneSevenLiveStreamingDock::setupUi() { } // Add new method for loading room information -void OneSevenLiveStreamingDock::loadRoomInfo(qint64 roomID) { - // Show loading state - isLoading = true; - loadingOverlay->setVisible(true); - loadingOverlay->raise(); // Ensure overlay is on top - loadingLabel->setText(obs_module_text("Live.Settings.Loading")); - - // Disable all controls - QScrollArea *scrollArea = qobject_cast(widget()); - if (scrollArea && scrollArea->widget()) { - scrollArea->widget()->setEnabled(false); +void OneSevenLiveStreamingDock::loadRoomInfo() { + if (!streamManager) { + return; } - // TODO: The following code needs optimization, establish Worker class, put API calls in Worker - // class, send signals in Worker class, receive signals in main thread, update UI Create a new - // thread to execute API calls, avoiding UI blocking - QThread *thread = new QThread; - QObject *worker = new QObject; - worker->moveToThread(thread); - - connect(thread, &QThread::started, worker, [this, roomID, worker, thread]() { - // Execute API calls in new thread - bool roomInfoSuccess = apiWrapper->GetRoomInfo(roomID, roomInfo); + streamManager->loadRoomInfo(); - std::string region; - configManager->getConfigValue("Region", region); - std::string language = GetCurrentLanguage(); - - std::string userID; - configManager->getConfigValue("UserID", userID); - - // Get configStreamer information in the same thread - bool configStreamerSuccess = - apiWrapper->GetConfigStreamer(region, language, configStreamer); - - bool userInfoSuccess = apiWrapper->GetUserInfo(userID, region, language, userInfo); - - bool levelsSuccess = apiWrapper->GetArmySubscriptionLevels(region, language, levels); - - // Use Qt::QueuedConnection to ensure UI updates in main thread - QMetaObject::invokeMethod( - this, - [this, roomInfoSuccess, configStreamerSuccess, userInfoSuccess, levelsSuccess]() { - // Hide loading state - isLoading = false; - loadingOverlay->setVisible(false); - - // Enable all controls - QScrollArea *scrollArea = qobject_cast(widget()); - if (scrollArea && scrollArea->widget()) { - scrollArea->widget()->setEnabled(true); - } - - if (configStreamerSuccess) { - // Update UI - updateUIWithRoomInfo(); - } else { - // Show error message - QMessageBox::warning( - this, obs_module_text("Live.Settings.Error"), - QString::fromStdString(obs_module_text("Live.Settings.LoadError")) - .arg(apiWrapper->getLastErrorMessage())); - } - - if (!roomInfoSuccess) { - obs_log(LOG_WARNING, "Failed to get roomInfo in loadRoomInfo"); - } + if (streamManager && streamManager->isRoomInfoLoading()) { + loadingOverlay->setVisible(true); + loadingOverlay->raise(); + loadingLabel->setText(obs_module_text("Live.Settings.Loading")); + QScrollArea *scrollArea = qobject_cast(widget()); + if (scrollArea && scrollArea->widget()) { + scrollArea->widget()->setEnabled(false); + } - if (!userInfoSuccess) { - obs_log(LOG_WARNING, "Failed to get user info in loadRoomInfo"); - } + // connect streamManager's roomInfoLoaded signal to updateUIWithRoomInfo slot - if (!levelsSuccess) { - obs_log(LOG_WARNING, "Failed to get army subscription levels in loadRoomInfo"); - } - }, - Qt::QueuedConnection); + connect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, + [this](const OneSevenLiveLoadRoomInfoWorker::LoadResult &result) { + roomInfo = streamManager->getRoomInfo(); + configStreamer = streamManager->getConfigStreamer(); + userInfo = streamManager->getUserInfo(); + levels = streamManager->getArmyLevels(); - // Clean up after completion - thread->quit(); - worker->deleteLater(); - }); + handleLoadingCompleted(result); + }); + } +} - connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); +void OneSevenLiveStreamingDock::showEvent(QShowEvent *event) { + QDockWidget::showEvent(event); + if (streamManager && streamManager->isRoomInfoLoading()) { + loadingOverlay->setVisible(true); + loadingOverlay->raise(); + loadingLabel->setText(obs_module_text("Live.Settings.Loading")); + QScrollArea *scrollArea = qobject_cast(widget()); + if (scrollArea && scrollArea->widget()) { + scrollArea->widget()->setEnabled(false); + } + } else { + loadingOverlay->setVisible(false); + QScrollArea *scrollArea = qobject_cast(widget()); + if (scrollArea && scrollArea->widget()) { + scrollArea->widget()->setEnabled(true); + } + } } // Add new method to update UI based on roomInfo void OneSevenLiveStreamingDock::updateUIWithRoomInfo() { - // obs_log(LOG_INFO, "Updating UI with room info"); + obs_log(LOG_INFO, "Updating UI with room info"); hashtagSelectLimit = configStreamer.hashtagSelectLimit; @@ -578,11 +664,13 @@ void OneSevenLiveStreamingDock::updateUIWithRoomInfo() { clipIdentityCombo->setCurrentIndex( clipIdentityCombo->findData(configStreamer.archiveConfig.clipPermission)); - if (roomInfo.status == static_cast(OneSevenLiveStreamingStatus::Live) || - roomInfo.status == static_cast(OneSevenLiveStreamingStatus::Streaming)) { - updateUIValues(); + if (roomInfo.status != static_cast(OneSevenLiveStreamingStatus::Live) && + roomInfo.status != static_cast(OneSevenLiveStreamingStatus::Streaming)) { + return; } + updateUIValues(); + // How to handle when web has already started streaming if (roomInfo.status == static_cast(OneSevenLiveStreamingStatus::Live)) { // Add user prompt dialog to ask for next operation @@ -598,47 +686,160 @@ void OneSevenLiveStreamingDock::updateUIWithRoomInfo() { msgBox.setDefaultButton(startLiveOnlyButton); msgBox.exec(); - if (msgBox.clickedButton() == startLiveOnlyButton) { - syncWithWeb(static_cast(roomInfo.status)); - } else if (msgBox.clickedButton() == closeLiveButton) { - closeLive(roomInfo.userInfo.userID.toStdString(), - QString::number(roomInfo.liveStreamID).toStdString()); + if (msgBox.clickedButton() == closeLiveButton) { + streamManager->stopStream(false); + return; } - } else if (roomInfo.status == static_cast(OneSevenLiveStreamingStatus::Streaming)) { - syncWithWeb(static_cast(roomInfo.status)); } + + connect(streamManager, &OneSevenLiveStreamManager::webStreamSettingsLoaded, this, + [this](bool success) { + disconnect(streamManager, &OneSevenLiveStreamManager::webStreamSettingsLoaded, this, + nullptr); + if (success) { + startEventCooldown(); + startLive(!(roomInfo.status == + static_cast(OneSevenLiveStreamingStatus::Streaming))); + } else { + obs_log(LOG_ERROR, "Failed to load web stream settings"); + } + }); + streamManager->startStreamWithWebAsync(); } -void OneSevenLiveStreamingDock::syncWithWeb(OneSevenLiveStreamingStatus status) { - if (roomInfo.rtmpUrls.size() > 0) { - QString provider = GetProviderNameByIndex(roomInfo.rtmpUrls[0].provider); - OneSevenLiveRtmpResponse rtmpResponse; - if (apiWrapper->GetRtmpByProvider(provider.toStdString(), rtmpResponse)) { - rtmpResponse.liveStreamID = QString::number(roomInfo.liveStreamID); - startLive(roomInfo.userInfo.userID.toStdString(), rtmpResponse, - roomInfo.archiveConfig.autoRecording, - status == OneSevenLiveStreamingStatus::Streaming); - } else { - QMessageBox::warning( - this, obs_module_text("Live.Settings.Error"), - QString::fromStdString(obs_module_text("Live.Settings.GetRtmpError")) - .arg(apiWrapper->getLastErrorMessage())); +// Handle loading completion with comprehensive error handling +void OneSevenLiveStreamingDock::handleLoadingCompleted( + const OneSevenLiveLoadRoomInfoWorker::LoadResult &result) { + obs_log(LOG_INFO, "OneSevenLiveStreamingDock::handleLoadingCompleted"); + + loadingOverlay->setVisible(false); + + // Enable all controls + QScrollArea *scrollArea = qobject_cast(widget()); + if (scrollArea && scrollArea->widget()) { + scrollArea->widget()->setEnabled(true); + } + + // Log any API failures + if (!result.roomInfoSuccess) { + obs_log(LOG_WARNING, "Failed to get roomInfo in loadRoomInfo"); + } + if (!result.userInfoSuccess) { + obs_log(LOG_WARNING, "Failed to get user info in loadRoomInfo"); + } + if (!result.levelsSuccess) { + obs_log(LOG_WARNING, "Failed to get army subscription levels in loadRoomInfo"); + } + + // Check for critical error message first + if (!result.errorMessage.empty()) { + // Critical failure - show non-blocking error with retry option + QMessageBox *msgBox = new QMessageBox(this); + msgBox->setIcon(QMessageBox::Critical); + msgBox->setWindowTitle(obs_module_text("Live.Settings.Error")); + msgBox->setText(QString::fromStdString(result.errorMessage)); + + QPushButton *retryButton = + msgBox->addButton(obs_module_text("Live.Settings.Retry"), QMessageBox::ActionRole); + msgBox->addButton(QMessageBox::Cancel); + msgBox->setDefaultButton(retryButton); + msgBox->setAttribute(Qt::WA_DeleteOnClose); + + connect(msgBox, &QMessageBox::finished, this, [this, msgBox, retryButton]() { + if (msgBox->clickedButton() == retryButton) { + // Get current room ID and retry loading + qint64 currentRoomID = streamManager->getRoomID(); + + if (currentRoomID > 0) { + loadRoomInfo(); + } + } + }); + + msgBox->show(); + return; + } + + // Check if we have all required data for updateUIWithRoomInfo + // Required: configStreamer, roomInfo, userInfo + // levels is required only when configStreamer.armyOnly == 2 && userInfo.onliveInfo.premiumType + // != 1 + bool hasRequiredData = + result.configStreamerSuccess && result.roomInfoSuccess && result.userInfoSuccess; + + // Check if levels is required based on army settings + bool levelsRequired = false; + if (result.configStreamerSuccess && result.userInfoSuccess) { + levelsRequired = (configStreamer.armyOnly == 2 && userInfo.onliveInfo.premiumType != 1); + if (levelsRequired) { + hasRequiredData = hasRequiredData && result.levelsSuccess; } - } else { - QMessageBox::warning( - this, obs_module_text("Live.Settings.Error"), - QString::fromStdString(obs_module_text("Live.Settings.GetRoomInfoError")) - .arg(apiWrapper->getLastErrorMessage())); } + + if (!hasRequiredData) { + // Log detailed information for debugging + QStringList missingDataDetails; + if (!result.configStreamerSuccess) { + missingDataDetails << "ConfigStreamer"; + } + if (!result.roomInfoSuccess) { + missingDataDetails << "RoomInfo"; + } + if (!result.userInfoSuccess) { + missingDataDetails << "UserInfo"; + } + if (levelsRequired && !result.levelsSuccess) { + missingDataDetails << "Levels (required for army settings)"; + } + + obs_log(LOG_WARNING, + "[17Live] Failed to load required streaming configuration data. Missing: %s", + missingDataDetails.join(", ").toUtf8().constData()); + + // Show simplified user message consistent with error message box above + QMessageBox *msgBox = new QMessageBox(this); + msgBox->setIcon(QMessageBox::Warning); + msgBox->setWindowTitle(obs_module_text("Live.Settings.Warning")); + msgBox->setText(obs_module_text("Live.Settings.RequiredDataMissing")); + + QPushButton *retryButton = + msgBox->addButton(obs_module_text("Live.Settings.Retry"), QMessageBox::ActionRole); + msgBox->addButton(QMessageBox::Cancel); + msgBox->setDefaultButton(retryButton); + msgBox->setAttribute(Qt::WA_DeleteOnClose); + + connect(msgBox, &QMessageBox::finished, this, [this, msgBox, retryButton]() { + if (msgBox->clickedButton() == retryButton) { + // Get current room ID and retry loading + qint64 currentRoomID = streamManager->getRoomID(); + if (currentRoomID > 0) { + obs_log(LOG_INFO, "[17Live] User requested retry for room ID: %lld", + currentRoomID); + loadRoomInfo(); + } + } + }); + + msgBox->show(); + return; + } + + // Show warnings for non-critical failures (levels is optional when not required for army + // settings) + if (!levelsRequired && !result.levelsSuccess) { + obs_log(LOG_INFO, + "[17Live] Levels data failed to load but not required for current army settings"); + // No user notification needed when levels is not required + } + + // All required data loaded successfully - update UI + updateUIWithRoomInfo(); } void OneSevenLiveStreamingDock::updateRequiredArmyRankSelections() { // obs_log(LOG_INFO, "updateRequiredArmyRankSelections"); OneSevenLiveConfig config; - if (!configManager->getConfig(config)) { - return; - } // Initialize Combo Items requiredArmyRankCombo->clear(); @@ -780,7 +981,7 @@ void OneSevenLiveStreamingDock::onCustomEventToggleClicked() { customEventToggleButton->setIcon(QIcon(":/resources/arrow-down.svg")); customEventDialog->close(); - delete customEventDialog; + customEventDialog->deleteLater(); customEventDialog = nullptr; } else { // Open dialog first; dialog will fetch custom event asynchronously @@ -853,8 +1054,9 @@ void OneSevenLiveStreamingDock::updateTagsFromList() { // Clear existing tag display QLayoutItem *child; while ((child = tagsLayout->takeAt(0)) != nullptr) { - if (child->widget()) { - child->widget()->deleteLater(); + if (QWidget *w = child->widget()) { + w->setParent(nullptr); + delete w; } delete child; } @@ -899,6 +1101,8 @@ void OneSevenLiveStreamingDock::updateTagsFromList() { } void OneSevenLiveStreamingDock::onSaveConfigClicked() { + if (!streamManager) + return; OneSevenLiveRtmpRequest request; if (!gatherRtmpRequest(request)) { obs_log(LOG_ERROR, "Failed to gather rtmp request"); @@ -920,7 +1124,7 @@ void OneSevenLiveStreamingDock::onSaveConfigClicked() { streamInfo.streamUuid = QUuid::createUuid().toString(QUuid::WithoutBraces); } - if (!configManager->saveLiveConfig(streamInfo)) { + if (!streamManager->saveStreamConfiguration(streamInfo)) { obs_log(LOG_ERROR, "Failed to save stream info"); return; } @@ -942,46 +1146,39 @@ void OneSevenLiveStreamingDock::onCreateLiveClicked() { return; } - createLive(request); + startCreateLiveSequence(request); } void OneSevenLiveStreamingDock::createLiveWithRequest(const OneSevenLiveRtmpRequest &request) { obs_log(LOG_INFO, "createLiveWithRequest"); - if (isLoading) { + if (!streamManager) + return; + + if (streamManager && streamManager->isRoomInfoLoading()) { // loading roomInfo is in progress, waiting for it to finish obs_log(LOG_INFO, "Waiting for loading to complete before creating live"); - // Create a timer to periodically check if loading is complete - QTimer *waitTimer = new QTimer(this); - waitTimer->setSingleShot(false); - waitTimer->setInterval(100); // Check every 100ms + // connect streamManager's roomInfoLoaded signal to updateUIWithRoomInfo slot + disconnect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, nullptr); + connect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, + [this, request](const OneSevenLiveLoadRoomInfoWorker::LoadResult &result) { + roomInfo = streamManager->getRoomInfo(); + configStreamer = streamManager->getConfigStreamer(); + userInfo = streamManager->getUserInfo(); + levels = streamManager->getArmyLevels(); - connect(waitTimer, &QTimer::timeout, this, [this, request, waitTimer]() { - if (!isLoading) { - // Loading is complete, stop timer and proceed with creation - waitTimer->stop(); - waitTimer->deleteLater(); + handleLoadingCompleted(result); - obs_log(LOG_INFO, "Loading completed, proceeding with live creation"); + populateRtmpRequest(request); - if (roomInfo.status != static_cast(OneSevenLiveStreamingStatus::NotStarted)) { - obs_log(LOG_INFO, - "Room is starting live stream, don't proceed with live creation"); - return; - } + if (request.caption.isEmpty() || request.subtabID.isEmpty()) { + return; + } - populateRtmpRequest(request); + startCreateLiveSequence(request); + }); - if (request.caption.isEmpty() || request.subtabID.isEmpty()) { - return; - } - - createLive(request); - } - }); - - waitTimer->start(); return; } @@ -991,41 +1188,34 @@ void OneSevenLiveStreamingDock::createLiveWithRequest(const OneSevenLiveRtmpRequ return; } - createLive(request); + startCreateLiveSequence(request); } void OneSevenLiveStreamingDock::editLiveWithInfo(const OneSevenLiveStreamInfo &info) { obs_log(LOG_INFO, "editLiveWithInfo"); - if (isLoading) { + if (!streamManager) + return; + + if (streamManager && streamManager->isRoomInfoLoading()) { // loading roomInfo is in progress, waiting for it to finish obs_log(LOG_INFO, "Waiting for loading to complete before editing live info"); - // Create a timer to periodically check if loading is complete - QTimer *waitTimer = new QTimer(this); - waitTimer->setSingleShot(false); - waitTimer->setInterval(100); // Check every 100ms - - connect(waitTimer, &QTimer::timeout, this, [this, info, waitTimer]() { - if (!isLoading) { - // Loading is complete, stop timer and proceed with creation - waitTimer->stop(); - waitTimer->deleteLater(); + // connect streamManager's roomInfoLoaded signal to updateUIWithRoomInfo slot + disconnect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, nullptr); + connect(streamManager, &OneSevenLiveStreamManager::roomInfoLoaded, this, + [this, info](const OneSevenLiveLoadRoomInfoWorker::LoadResult &result) { + roomInfo = streamManager->getRoomInfo(); + configStreamer = streamManager->getConfigStreamer(); + userInfo = streamManager->getUserInfo(); + levels = streamManager->getArmyLevels(); - obs_log(LOG_INFO, "Loading completed, proceeding with live creation"); + handleLoadingCompleted(result); - if (roomInfo.status != static_cast(OneSevenLiveStreamingStatus::NotStarted)) { - obs_log(LOG_INFO, - "Room is starting live stream, don't proceed with live creation"); - return; - } - - populateRtmpRequest(info.request); - currentInfoUuid = info.streamUuid; - } - }); + populateRtmpRequest(info.request); + currentInfoUuid = info.streamUuid; + }); - waitTimer->start(); return; } @@ -1033,38 +1223,97 @@ void OneSevenLiveStreamingDock::editLiveWithInfo(const OneSevenLiveStreamInfo &i currentInfoUuid = info.streamUuid; } -void OneSevenLiveStreamingDock::createLive(const OneSevenLiveRtmpRequest &request_) { - obs_log(LOG_INFO, "createLive"); +void OneSevenLiveStreamingDock::startCreateLiveSequence(const OneSevenLiveRtmpRequest &request_) { + obs_log(LOG_INFO, "startCreateLiveSequence (Async)"); - // check current region changed? + if (!streamManager) + return; + + // Disable button to prevent double click + createLiveButton->setEnabled(false); + + // Show loading overlay + loadingOverlay->setVisible(true); + loadingOverlay->raise(); + loadingLabel->setText(obs_module_text("Live.Settings.Loading")); + + // Prepare data for async task std::string currentRegion; configManager->getConfigValue("Region", currentRegion); - // Check feature 207 to control createLiveButton state - OneSevenLiveConfig currentConfig; - configManager->getConfig(currentConfig); - bool currentIsFeature207Enabled = (currentConfig.addOns.features["207"] == 1); + // Capture necessary pointers and data + auto *api = this->apiWrapper; + QPointer self = this; + OneSevenLiveRtmpRequest requestCopy = request_; + + ScheduleOBSTask([self, api, currentRegion, requestCopy]() { + OneSevenLiveLoginData loginData; + nlohmann::json configJson; + bool selfInfoSuccess = false; + bool configSuccess = false; + QString errorMsg; + + if (api) { + selfInfoSuccess = api->GetSelfInfo(loginData); + if (!selfInfoSuccess) { + errorMsg = api->getLastErrorMessage(); + } + + if (selfInfoSuccess && + loginData.userInfo.region != QString::fromStdString(currentRegion)) { + std::string language = GetCurrentLanguage(); + // Region changed, need to fetch config + configSuccess = api->GetConfig(currentRegion, language, configJson); + } + } - OneSevenLiveLoginData loginData; - if (!apiWrapper->GetSelfInfo(loginData)) { - obs_log(LOG_ERROR, "GetSelfInfo failed"); + if (self) { + QMetaObject::invokeMethod( + self, + [self, selfInfoSuccess, configSuccess, loginData, configJson, requestCopy, + errorMsg]() { + if (self) { + self->handleCreateLiveChecks(loginData, configJson, selfInfoSuccess, + errorMsg, requestCopy); + } + }, + Qt::QueuedConnection); + } + }); +} + +void OneSevenLiveStreamingDock::handleCreateLiveChecks(const OneSevenLiveLoginData &loginData, + const nlohmann::json &configJson, + bool success, const QString &error, + const OneSevenLiveRtmpRequest &request_) { + loadingOverlay->setVisible(false); + createLiveButton->setEnabled(true); + + if (!success) { + obs_log(LOG_ERROR, "GetSelfInfo failed: %s", error.toStdString().c_str()); QMessageBox::warning(this, obs_module_text("Live.Create.Title"), obs_module_text("Live.Create.GetSelfInfoFailed")); return; } + // check current region changed? + std::string currentRegion; + configManager->getConfigValue("Region", currentRegion); + + // Check feature 207 to control createLiveButton state + OneSevenLiveConfig currentConfig; + configManager->getConfig(currentConfig); + bool currentIsFeature207Enabled = (currentConfig.addOns.features["207"] == 1); + if (loginData.userInfo.region != QString::fromStdString(currentRegion)) { obs_log(LOG_INFO, "Region changed, reload config"); - std::string language = GetCurrentLanguage(); - // Call API to get configuration - nlohmann::json configJson; - if (apiWrapper->GetConfig(currentRegion, language, configJson)) { - // Save configuration + // Save configuration + if (!configJson.empty()) { configManager->setConfig(configJson); obs_log(LOG_INFO, "Config loaded successfully"); } else { - obs_log(LOG_ERROR, "Failed to load config from API"); + obs_log(LOG_ERROR, "Failed to load config from API (or it was empty)"); } OneSevenLiveConfig newConfig; @@ -1079,9 +1328,7 @@ void OneSevenLiveStreamingDock::createLive(const OneSevenLiveRtmpRequest &reques } else if (!currentIsFeature207Enabled && request_.subtabID.isEmpty()) { // Feature 207 enabled now, but subtabID is empty, show warning // Show dialog to prompt user to select category - loadRoomInfo(loginData.userInfo.roomID); - - QMessageBox::warning(this, obs_module_text("Live.Settings.Save.Title"), + QMessageBox::warning(this, obs_module_text("Live.Create.Title"), obs_module_text("Live.Settings.Save.Category.Empty")); return; @@ -1092,6 +1339,7 @@ void OneSevenLiveStreamingDock::createLive(const OneSevenLiveRtmpRequest &reques return; } + // Validate request OneSevenLiveRtmpRequest request = request_; if (request.caption.isEmpty()) { @@ -1101,134 +1349,55 @@ void OneSevenLiveStreamingDock::createLive(const OneSevenLiveRtmpRequest &reques return; } - // if (request.subtabID.isEmpty()) { - // // Show dialog to prompt user to select category - // QMessageBox::warning(this, obs_module_text("Live.Settings.Save.Title"), - // obs_module_text("Live.Settings.Save.Category.Empty")); - // return; - // } - - // Add current userID and streamerType to request - std::string userID; - configManager->getConfigValue("UserID", userID); - request.userID = QString::fromStdString(userID); - request.streamerType = roomInfo.streamerType; - - OneSevenLiveRtmpResponse response; - if (!apiWrapper->CreateRtmp(request, response)) { - QString errorMsg = apiWrapper->getLastErrorMessage(); - obs_log(LOG_ERROR, "Failed to create stream. UserID: %s, Error: %s, Timestamp: %lld", - request.userID.toStdString().c_str(), - errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str(), - QDateTime::currentMSecsSinceEpoch()); - return; - } - - emit streamStatusUpdated(OneSevenLiveStreamingStatus::Live); - - startLive(request.userID.toStdString(), response, request.archiveConfig.autoRecording); + // Use stream manager to create live stream + createLiveButton->setEnabled(false); + streamManager->createRtmpAsync(request); } -void OneSevenLiveStreamingDock::startLive(const std::string userID, - const OneSevenLiveRtmpResponse &response, - bool autoRecording, bool skip) { - // Check if WHIP information is available - bool hasWhipInfo = !response.whipInfo.server.isEmpty() && !response.whipInfo.token.isEmpty(); - - if (hasWhipInfo) { - // WHIP mode - obs_log(LOG_INFO, "Using WHIP streaming mode"); - - // Save WHIP streaming settings - configManager->setWhipStreamingInfo(response.liveStreamID.toStdString(), - response.whipInfo.server.toStdString(), - response.whipInfo.token.toStdString()); - configManager->setWhipMode(true); - - saveWhipStreamingSettings(response.liveStreamID.toStdString(), - response.whipInfo.server.toStdString(), - response.whipInfo.token.toStdString()); - } else { - // RTMP mode - obs_log(LOG_INFO, "Using RTMP streaming mode"); - - QString streamUrl; - QString streamKey; - - // Regular expression /(^.+:\/\/[^/]+\/[^/]+)\/(.+)$/ to parse response.rtmpURL - // First captured group is streamUrl, second captured group is streamKey - // Example: - // rtmp://live-push.bilivideo.com/live-bvc/1234567890?expire=1680000000&usign=abcdefg - QRegularExpression re("(^.+://[^/]+/[^/]+)/(.+)$"); - QRegularExpressionMatch match = re.match(response.rtmpURL); - if (match.hasMatch()) { - streamUrl = match.captured(1); - streamKey = match.captured(2); - } else { - obs_log(LOG_ERROR, "Failed to parse stream url"); - return; - } - - configManager->setStreamingInfo(response.liveStreamID.toStdString(), - streamUrl.toStdString(), streamKey.toStdString()); - configManager->setWhipMode(false); +void OneSevenLiveStreamingDock::createLive(const OneSevenLiveRtmpRequest &request_) { + startCreateLiveSequence(request_); +} - saveStreamingSettings(response.liveStreamID.toStdString(), streamUrl.toStdString(), - streamKey.toStdString()); - } +void OneSevenLiveStreamingDock::startLive(bool startStream) { + obs_log(LOG_INFO, "Starting live stream"); - // Start live stream - if (!skip && !apiWrapper->StartStream(response.liveStreamID.toStdString(), userID)) { - QString errorMsg = apiWrapper->getLastErrorMessage(); - obs_log(LOG_ERROR, - "Failed to start stream. LiveStreamID: %s, UserID: %s, Error: %s, Timestamp: %lld", - response.liveStreamID.toStdString().c_str(), userID.c_str(), - errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str(), - QDateTime::currentMSecsSinceEpoch()); + if (!streamManager) return; - } - // archive - if (!skip && autoRecording) { - if (!apiWrapper->EnableStreamArchive(response.liveStreamID.toStdString(), 1)) { - QString errorMsg = apiWrapper->getLastErrorMessage(); - obs_log(LOG_ERROR, - "Failed to enable archive. LiveStreamID: %s, UserID: %s, Error: %s, Timestamp: " - "%lld", - response.liveStreamID.toStdString().c_str(), userID.c_str(), - errorMsg.isEmpty() ? "Unknown error" : errorMsg.toStdString().c_str(), - QDateTime::currentMSecsSinceEpoch()); - } - } - - updateLiveStatus(OneSevenLiveStreamingStatus::Streaming); - emit streamStatusUpdated(OneSevenLiveStreamingStatus::Streaming); - - // Start event cooldown after successful live creation - startEventCooldown(); + if (startStream) { + // Start streaming (server-side) + createLiveButton->setEnabled(false); + streamManager->startStreamAsync(); + } else { + // update streaming status + streamManager->setCurrentStreamingStatus(OneSevenLiveStreamingStatus::Streaming); - // Ask whether to start streaming simultaneously - QMessageBox msgBox; - msgBox.setWindowTitle(obs_module_text("Live.Settings.StartStreaming")); - msgBox.setText(obs_module_text("Live.Settings.StartStreaming.Tip")); + // Ask whether to start streaming simultaneously + QMessageBox msgBox; + msgBox.setWindowTitle(obs_module_text("Live.Settings.StartStreaming")); + msgBox.setText(obs_module_text("Live.Settings.StartStreaming.Tip")); - // Use localized button text - QPushButton *yesButton = - msgBox.addButton(obs_module_text("Live.Settings.Yes"), QMessageBox::YesRole); - /* QPushButton *noButton = */ msgBox.addButton(obs_module_text("Live.Settings.No"), - QMessageBox::NoRole); - msgBox.setDefaultButton(yesButton); + // Use localized button text + QPushButton *yesButton = + msgBox.addButton(obs_module_text("Live.Settings.Yes"), QMessageBox::YesRole); + /* QPushButton *noButton = */ msgBox.addButton(obs_module_text("Live.Settings.No"), + QMessageBox::NoRole); + msgBox.setDefaultButton(yesButton); - msgBox.exec(); - if (msgBox.clickedButton() == yesButton) { - // Start OBS streaming - obs_frontend_streaming_start(); + msgBox.exec(); + if (msgBox.clickedButton() == yesButton) { + // Start OBS streaming + streamManager->startOBSStreaming(); + } } } void OneSevenLiveStreamingDock::onDeleteLiveClicked() { obs_log(LOG_INFO, "onDeleteLiveClicked"); + if (!streamManager) + return; + // Add confirmation dialog QMessageBox msgBox; msgBox.setWindowTitle(obs_module_text("Live.Settings.CloseLive")); @@ -1247,120 +1416,20 @@ void OneSevenLiveStreamingDock::onDeleteLiveClicked() { return; } - std::string currUserID; - std::string currLiveStreamID; - configManager->getConfigValue("UserID", currUserID); - configManager->getConfigValue("LiveStreamID", currLiveStreamID); - - closeLive(currUserID, currLiveStreamID); -} - -void OneSevenLiveStreamingDock::closeLive(const std::string &currUserID, - const std::string &currLiveStreamID, bool isAutoClose) { - // Handle stop streaming logic - stopStreaming(); - - QString endReason = isAutoClose ? "autoClose" : "normalEnd"; - - // Send close live stream request - OneSevenLiveCloseLiveRequest request; - request.reason = "normalEnd"; - request.userID = QString::fromStdString(currUserID); - - if (!apiWrapper->StopStream(currLiveStreamID, request)) { - obs_log(LOG_ERROR, "Failed to stop stream. LiveStreamID: %s, Reason: %s", - currLiveStreamID.c_str(), endReason.toStdString().c_str()); - // return; - } else { - obs_log(LOG_INFO, - "Successfully stopped stream. LiveStreamID: %s, Reason: %s, IsAutoClose: %s", - currLiveStreamID.c_str(), endReason.toStdString().c_str(), - isAutoClose ? "true" : "false"); - } - - // Clear streaming configuration based on current mode - if (configManager->isWhipMode()) { - configManager->clearWhipStreamingInfo(); - } else { - configManager->clearStreamingInfo(); - } - configManager->setWhipMode(false); - - updateLiveStatus(OneSevenLiveStreamingStatus::NotStarted); - emit streamStatusUpdated(OneSevenLiveStreamingStatus::NotStarted); -} - -void OneSevenLiveStreamingDock::saveStreamingSettings(const std::string &liveStreamID, - const std::string &streamUrl, - const std::string &streamKey) { - // Handle start streaming logic - obs_log(LOG_INFO, "saveStreamingSettings %s", liveStreamID.c_str()); - - // Get OBS service - obs_service_t *service = obs_service_create("rtmp_custom", "default_service", NULL, NULL); - - // Set streaming URL and key - obs_data_t *settings = obs_service_get_settings(service); - obs_log(LOG_INFO, "streamUrl: %s", streamUrl.c_str()); - obs_log(LOG_INFO, "streamKey: %s", streamKey.c_str()); - obs_data_set_string(settings, "server", streamUrl.c_str()); - obs_data_set_string(settings, "key", streamKey.c_str()); - - // Apply settings - obs_service_update(service, settings); - obs_data_release(settings); - - obs_frontend_set_streaming_service(service); - - obs_frontend_save_streaming_service(); - - // Release resources - obs_service_release(service); -} - -void OneSevenLiveStreamingDock::saveWhipStreamingSettings(const std::string &liveStreamID, - const std::string &whipServer, - const std::string &whipToken) { - // Handle WHIP streaming settings - obs_log(LOG_INFO, "saveWhipStreamingSettings %s", liveStreamID.c_str()); - obs_log(LOG_INFO, "whipServer: %s", whipServer.c_str()); - obs_log(LOG_INFO, "whipToken: %s", whipToken.c_str()); - - // Set WHIP server and token - obs_data_t *settings = obs_data_create(); - obs_data_set_string(settings, "type", "whip_custom"); - obs_data_set_string(settings, "service", "WHIP"); - obs_data_set_string(settings, "server", whipServer.c_str()); - obs_data_set_string(settings, "bearer_token", whipToken.c_str()); - - // Get or create WHIP service - obs_service_t *service = obs_service_create("whip_custom", "whip_service", settings, NULL); - if (!service) { - obs_log(LOG_ERROR, "Failed to create WHIP service"); - return; + // check other live streams + bool multiActive = false; + if (OneSevenLiveMultiRtmpManager::peekInstance() && + OneSevenLiveMultiRtmpManager::peekInstance()->isInitialized()) { + auto ids = OneSevenLiveMultiRtmpManager::peekInstance()->getActiveStreamIds(); + multiActive = !ids.empty(); } - - // Set as current streaming service - obs_frontend_set_streaming_service(service); - - obs_service_release(service); - obs_data_release(settings); - - obs_frontend_save_streaming_service(); - - obs_log(LOG_INFO, "WHIP service configured successfully"); -} - -void OneSevenLiveStreamingDock::stopStreaming() { - // Handle stop streaming logic - obs_log(LOG_INFO, "stopStreaming"); - - if (!obs_frontend_streaming_active()) { - obs_log(LOG_INFO, "Streaming is not active"); - return; + if (multiActive) { + QMessageBox::information(this, obs_module_text("Live.Common.Notice"), + obs_module_text("MultiRTMP.CloseLive.InfoTip")); } - obs_frontend_streaming_stop(); + // Stop streaming + streamManager->stopStream(false); } void OneSevenLiveStreamingDock::populateRtmpRequest(const OneSevenLiveRtmpRequest &request) { @@ -1456,8 +1525,10 @@ void OneSevenLiveStreamingDock::updateLiveButton(bool isLive) { createLiveButton->setStyleSheet("background-color: #215EBC; color: white;"); disconnect(createLiveButton, &QPushButton::clicked, this, &OneSevenLiveStreamingDock::onCreateLiveClicked); + disconnect(createLiveButton, &QPushButton::clicked, this, + &OneSevenLiveStreamingDock::onDeleteLiveClicked); connect(createLiveButton, &QPushButton::clicked, this, - &OneSevenLiveStreamingDock::onDeleteLiveClicked); + &OneSevenLiveStreamingDock::onDeleteLiveClicked, Qt::UniqueConnection); } else { // change text to "Start Live" createLiveButton->setText(obs_module_text("Live.Settings.StartLive")); @@ -1465,16 +1536,32 @@ void OneSevenLiveStreamingDock::updateLiveButton(bool isLive) { createLiveButton->setStyleSheet("background-color: red; color: white;"); disconnect(createLiveButton, &QPushButton::clicked, this, &OneSevenLiveStreamingDock::onDeleteLiveClicked); + disconnect(createLiveButton, &QPushButton::clicked, this, + &OneSevenLiveStreamingDock::onCreateLiveClicked); connect(createLiveButton, &QPushButton::clicked, this, - &OneSevenLiveStreamingDock::onCreateLiveClicked); + &OneSevenLiveStreamingDock::onCreateLiveClicked, Qt::UniqueConnection); } } void OneSevenLiveStreamingDock::updateLiveStatus(OneSevenLiveStreamingStatus status) { + obs_log(LOG_INFO, "Updating live status to %d", status); + currentLiveStatus = status; updateLiveButton(status != OneSevenLiveStreamingStatus::NotStarted); + if (status == OneSevenLiveStreamingStatus::NotStarted) { + if (eventCooldownTimer && eventCooldownTimer->isActive()) { + eventCooldownTimer->stop(); + eventCooldownRemaining = 0; + if (hintLabel) { + hintLabel->setText(obs_module_text("Live.Settings.Event.Tip")); + hintLabel->setStyleSheet("color: gray; font-size: 12px;"); + } + emit eventCooldownUpdated(0); + } + } + // Disable ALL controls above the bottom buttons when streaming is active // Only keep save and create live buttons enabled bool isStreaming = (status == OneSevenLiveStreamingStatus::Live || @@ -1671,33 +1758,46 @@ void OneSevenLiveStreamingDock::onEventChanged(int index) { } // Call ChangeEvent API - OneSevenLiveChangeEventRequest request; - request.eventID = eventID; + changeEvent(eventID); +} - bool success = apiWrapper->ChangeEvent(request); - if (success) { - obs_log(LOG_INFO, "Successfully changed event to: %lld", eventID); +void OneSevenLiveStreamingDock::changeEvent(qint64 eventID) { + obs_log(LOG_INFO, "Changing event to: %lld", eventID); - // Start event cooldown - startEventCooldown(); - } else { - obs_log(LOG_ERROR, "Failed to change event to: %lld", eventID); - QMessageBox::warning(this, obs_module_text("Live.Common.Notice"), - obs_module_text("Live.ChangeEvent.Failed")); + if (!streamManager) + return; + + // Check if we're in cooldown + if (isEventInCooldown()) { + obs_log(LOG_INFO, "Event change ignored due to cooldown"); + return; } + + eventCombo->setEnabled(false); + + // Call ChangeEvent API + OneSevenLiveChangeEventRequest request; + request.eventID = eventID; + + streamManager->changeEventAsync(request); } -void OneSevenLiveStreamingDock::startEventCooldown() { - // Start cooldown timer (5 minutes = 300 seconds) - eventCooldownRemaining = 300; - originalCategoryText = eventCombo->currentText(); +void OneSevenLiveStreamingDock::startEventCooldown(int duration) { + eventCooldownRemaining = duration; eventCooldownTimer->start(); - // Disable event combo during cooldown + emit eventCooldownUpdated(eventCooldownRemaining); + obs_log(LOG_INFO, "Event cooldown started for %d seconds", duration); + originalCategoryText = eventCombo->currentText(); eventCombo->setEnabled(false); +} - // Update hint label to show cooldown - onEventCooldownTimeout(); // Update display immediately +bool OneSevenLiveStreamingDock::isEventInCooldown() const { + return eventCooldownTimer->isActive(); +} + +int OneSevenLiveStreamingDock::getEventCooldownRemaining() const { + return eventCooldownRemaining; } void OneSevenLiveStreamingDock::onEventCooldownTimeout() { @@ -1713,6 +1813,8 @@ void OneSevenLiveStreamingDock::onEventCooldownTimeout() { hintLabel->setText(cooldownText); hintLabel->setStyleSheet("color: orange; font-size: 12px;"); + + emit eventCooldownUpdated(eventCooldownRemaining); } else { // Cooldown finished eventCooldownTimer->stop(); @@ -1725,6 +1827,7 @@ void OneSevenLiveStreamingDock::onEventCooldownTimeout() { // Call updateLiveStatus to ensure consistent state handling across all UI elements updateLiveStatus(currentLiveStatus); - obs_log(LOG_INFO, "Event change cooldown finished"); + emit eventCooldownUpdated(0); + obs_log(LOG_INFO, "Event cooldown finished"); } } diff --git a/src/17live/streaming/OneSevenLiveStreamingDock.hpp b/src/17live/streaming/OneSevenLiveStreamingDock.hpp new file mode 100644 index 0000000..0896fb7 --- /dev/null +++ b/src/17live/streaming/OneSevenLiveStreamingDock.hpp @@ -0,0 +1,199 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveLoadRoomInfoWorker.hpp" +#include "api/OneSevenLiveModels.hpp" + +class OneSevenLiveCustomEventDialog; +class OneSevenLiveStreamManager; +class OneSevenLiveApiWrappers; +class OneSevenLiveConfigManager; + +class OneSevenLiveStreamingDock : public QDockWidget { + Q_OBJECT + + public: + explicit OneSevenLiveStreamingDock(QWidget *parent = nullptr, + OneSevenLiveStreamManager *streamManager = nullptr, + OneSevenLiveApiWrappers *apiWrappers = nullptr, + OneSevenLiveConfigManager *configManager = nullptr); + ~OneSevenLiveStreamingDock(); + + void updateLiveStatus(OneSevenLiveStreamingStatus status); + void createLiveWithRequest(const OneSevenLiveRtmpRequest &request); + void editLiveWithInfo(const OneSevenLiveStreamInfo &info); + void loadRoomInfo(); + + private: + void setupUi(); + void createConnections(); + void updateUIWithRoomInfo(); + void updateRequiredArmyRankSelections(); + void updateUIValues(); + void handleLoadingCompleted(const OneSevenLiveLoadRoomInfoWorker::LoadResult &result); + + /** + * @brief Change event during streaming + * @param eventID New event ID + */ + void changeEvent(qint64 eventID); + + /** + * @brief Start event cooldown timer + * @param duration Cooldown duration in seconds (default 300 = 5 minutes) + */ + void startEventCooldown(int duration = 300); + + /** + * @brief Check if event change is in cooldown + * @return bool True if cooldown is active + */ + bool isEventInCooldown() const; + + /** + * @brief Get remaining cooldown time + * @return int Remaining cooldown time in seconds + */ + int getEventCooldownRemaining() const; + + // Member variables + OneSevenLiveApiWrappers *apiWrapper = nullptr; + + private: + // UI elements + QLineEdit *titleEdit = nullptr; + QComboBox *categoryCombo = nullptr; + + // Tag area + QLineEdit *tagEdit = nullptr; + QPushButton *addTagButton = nullptr; + QWidget *tagsContainer = nullptr; // Container for displaying tags + QHBoxLayout *tagsLayout = nullptr; // Layout for tag container + QList tagsList; // Store current tag list + + // Streaming format + QRadioButton *landscapeStreamRadio = nullptr; + QRadioButton *portraitStreamRadio = nullptr; + + // Live mode - army-only viewing + QLabel *broadcastModeLabel = nullptr; + QWidget *armyOnlyHeader = nullptr; + QHBoxLayout *armyOnlyHeaderLayout = nullptr; + QLabel *armyOnlyLabel = nullptr; + QPushButton *armyOnlyToggleButton = nullptr; + QWidget *armyOnlyContainer = nullptr; + QVBoxLayout *armyOnlyContainerLayout = nullptr; + QCheckBox *armyOnlyCheck = nullptr; + QComboBox *requiredArmyRankCombo = nullptr; + QCheckBox *showInHotPageCheck = nullptr; + QCheckBox *liveNotificationCheck = nullptr; + bool armyOnlyExpanded = false; + + QComboBox *eventCombo = nullptr; + QLabel *hintLabel = nullptr; // Event hint label + + // Custom Event + QWidget *customEventHeader = nullptr; + QHBoxLayout *customEventHeaderLayout = nullptr; + QLabel *customEventLabel = nullptr; + QPushButton *customEventToggleButton = nullptr; + OneSevenLiveCustomEventDialog *customEventDialog = nullptr; + + // Party Live + QWidget *GroupCallContainer = nullptr; + QHBoxLayout *GroupCallContainerLayout = nullptr; + QLabel *GroupCallLabel = nullptr; + QPushButton *GroupCallHelpButton = nullptr; + QCheckBox *GroupCallCheck = nullptr; + + // Switches + QCheckBox *archiveStreamCheck = nullptr; + QCheckBox *autoPreviewCheck = nullptr; + + QComboBox *clipIdentityCombo = nullptr; + QCheckBox *virtualStreamerCheck = nullptr; + + // Bottom buttons + QPushButton *saveConfigButton = nullptr; + QPushButton *createLiveButton = nullptr; + + // Loading state UI + QWidget *loadingOverlay = nullptr; + QProgressBar *loadingProgress = nullptr; + QLabel *loadingLabel = nullptr; + + OneSevenLiveRoomInfo roomInfo; + OneSevenLiveConfigStreamer configStreamer; + OneSevenLiveUserInfo userInfo; + OneSevenLiveArmySubscriptionLevels levels; + + signals: + void streamInfoSaved(); + void eventCooldownUpdated(int remainingTime); + + private slots: + void onAddTagClicked(); + void onTagEnterPressed(); + void onRemoveTagClicked(); + void onCreateLiveClicked(); + void onDeleteLiveClicked(); + void onSaveConfigClicked(); + void onArmyOnlyToggleClicked(); // New collapse/expand button click event + void onArmyOnlyCheckChanged(int state); // Triggered when armyOnlyCheck state changes + void onCustomEventToggleClicked(); // Custom event toggle button click event + void onGroupCallHelpClicked(); // Party live help button click event + void onEventChanged(int index); // Event change event handler + void onEventCooldownTimeout(); // Event cooldown timer timeout handler + + bool gatherRtmpRequest(OneSevenLiveRtmpRequest &request); + void populateRtmpRequest(const OneSevenLiveRtmpRequest &request); + void updateLiveButton(bool isLive); + + void createLive(const OneSevenLiveRtmpRequest &request); + void startLive(bool startStream = true); + void startCreateLiveSequence(const OneSevenLiveRtmpRequest &request); + void handleCreateLiveChecks(const OneSevenLiveLoginData &loginData, + const nlohmann::json &configJson, bool success, + const QString &error, const OneSevenLiveRtmpRequest &request_); + + // Tag-related functions + void addTag(const QString &tag); + void updateTagsFromList(); + + private: + int hashtagSelectLimit = 2; // Maximum number of tags that can be added + + QPointer streamManager = nullptr; + OneSevenLiveConfigManager *configManager = nullptr; + + QString currentInfoUuid = ""; + // Loading state now controlled by OneSevenLiveStreamManager + OneSevenLiveStreamingStatus currentLiveStatus = OneSevenLiveStreamingStatus::NotStarted; + + // Category change cooldown timer + QTimer *eventCooldownTimer = nullptr; + int eventCooldownRemaining = 0; // Remaining cooldown time in seconds + QString originalCategoryText = ""; // Original category text before cooldown + int previousEventIndex = -1; // Store previous event index for confirmation dialog + static constexpr int DEFAULT_COOLDOWN_DURATION = 300; // 5 minutes + + protected: + void showEvent(QShowEvent *event) override; + void resizeEvent(QResizeEvent *event) override; +}; diff --git a/src/17live/OneSevenLiveStreamListDock.cpp b/src/17live/streamlist/OneSevenLiveStreamListDock.cpp similarity index 99% rename from src/17live/OneSevenLiveStreamListDock.cpp rename to src/17live/streamlist/OneSevenLiveStreamListDock.cpp index ec95144..d705c57 100644 --- a/src/17live/OneSevenLiveStreamListDock.cpp +++ b/src/17live/streamlist/OneSevenLiveStreamListDock.cpp @@ -5,24 +5,24 @@ // Qt widgets and helpers used in this translation unit #include +#include +#include +#include +#include #include #include +#include #include -#include #include #include -#include - -#include -#include -#include -#include #include #include +#include #include "api/OneSevenLiveModels.hpp" #include "OneSevenLiveConfigManager.hpp" #include "OneSevenLiveStreamListItem.hpp" +#include "api/OneSevenLiveModels.hpp" #include "moc_OneSevenLiveStreamListDock.cpp" #include "plugin-support.h" @@ -167,14 +167,13 @@ void OneSevenLiveStreamListDock::updateStreamItem(QListWidgetItem* item, buttonLayout->setAlignment(Qt::AlignRight); int btnCount = 2; - int btnSize = 24; + int btnSize = 24; int spacing = buttonLayout->spacing(); int margins = 0; int totalWidth = btnCount * btnSize + (btnCount - 1) * spacing + margins; buttonContainer->setFixedWidth(totalWidth); buttonContainer->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - QPushButton* editButton = new QPushButton(); editButton->setFixedSize(24, 24); diff --git a/src/17live/OneSevenLiveStreamListDock.hpp b/src/17live/streamlist/OneSevenLiveStreamListDock.hpp similarity index 88% rename from src/17live/OneSevenLiveStreamListDock.hpp rename to src/17live/streamlist/OneSevenLiveStreamListDock.hpp index a0de657..bcef345 100644 --- a/src/17live/OneSevenLiveStreamListDock.hpp +++ b/src/17live/streamlist/OneSevenLiveStreamListDock.hpp @@ -43,12 +43,12 @@ class OneSevenLiveStreamListDock : public QDockWidget { void updateStreamItem(QListWidgetItem* item, const OneSevenLiveStreamInfo& info); void showEmptyListMessage(); - QListWidget* streamList; - QPushButton* startLiveButton; + QListWidget* streamList = nullptr; + QPushButton* startLiveButton = nullptr; QWidget* emptyContainer = nullptr; - OneSevenLiveConfigManager* configManager; + OneSevenLiveConfigManager* configManager = nullptr; - QPushButton* goToStreamingButton; + QPushButton* goToStreamingButton = nullptr; OneSevenLiveStreamingStatus status; diff --git a/src/17live/OneSevenLiveStreamListItem.cpp b/src/17live/streamlist/OneSevenLiveStreamListItem.cpp similarity index 100% rename from src/17live/OneSevenLiveStreamListItem.cpp rename to src/17live/streamlist/OneSevenLiveStreamListItem.cpp diff --git a/src/17live/OneSevenLiveStreamListItem.hpp b/src/17live/streamlist/OneSevenLiveStreamListItem.hpp similarity index 66% rename from src/17live/OneSevenLiveStreamListItem.hpp rename to src/17live/streamlist/OneSevenLiveStreamListItem.hpp index 7004c4e..3047f07 100644 --- a/src/17live/OneSevenLiveStreamListItem.hpp +++ b/src/17live/streamlist/OneSevenLiveStreamListItem.hpp @@ -8,11 +8,11 @@ class OneSevenLiveStreamListItem : public QWidget { Q_OBJECT public: - QLabel* titleLabel; - QLabel* contentLabel; - QLabel* timestampLabel; - QPushButton* editButton; - QPushButton* deleteButton; + QLabel* titleLabel = nullptr; + QLabel* contentLabel = nullptr; + QLabel* timestampLabel = nullptr; + QPushButton* editButton = nullptr; + QPushButton* deleteButton = nullptr; OneSevenLiveStreamListItem(const QString& title, const QString& content, const QString& timestamp, QWidget* parent = nullptr); diff --git a/src/17live/twitch/OneSevenLiveTwitchAuth.cpp b/src/17live/twitch/OneSevenLiveTwitchAuth.cpp new file mode 100644 index 0000000..e0e380f --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchAuth.cpp @@ -0,0 +1,397 @@ +#include "OneSevenLiveTwitchAuth.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveConfigManager.hpp" +#include "OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveTwitchClient.hpp" +#include "plugin-support.h" +#include "utility/RemoteTextThread.hpp" + +// https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=hof5gwx0su6owfnys0nyan9c87zr6t&redirect_uri=http://localhost:3000&scope=channel%3Amanage%3Apolls+channel%3Aread%3Apolls&state=c3ab8aa609ea11e793ae92361f002671 +const QString OneSevenLiveTwitchAuth::TWITCH_DEVICE_AUTH_URL = + "https://id.twitch.tv/oauth2/" + "authorize?response_type=token&client_id=%1&redirect_uri=%2&scope=%3&state=%4"; +const QString OneSevenLiveTwitchAuth::TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; +const QString OneSevenLiveTwitchAuth::TWITCH_SCOPE = + "channel:read:stream_key channel:manage:broadcast user:read:email chat:read chat:edit"; +const QString OneSevenLiveTwitchAuth::TWITCH_CALLBACK_URI = "https://17.live"; +const QString OneSevenLiveTwitchAuth::PLATFORM = "Twitch"; + +OneSevenLiveTwitchAuth::OneSevenLiveTwitchAuth(QObject* parent) + : QObject(parent), + m_pollingTimer(new QTimer(this)), + m_expiresIn(0), + m_interval(5), + m_remainingTime(0), + m_isAuthorizing(false), + m_isPolling(false), + m_wasCancelled(false), + m_twitchClient(std::make_unique(this)) { + connect(m_pollingTimer, &QTimer::timeout, this, &OneSevenLiveTwitchAuth::pollForToken); + + // Connect Twitch client signals to handle user info retrieval + connect(m_twitchClient.get(), &OneSevenLiveTwitchClient::userInfoReceived, this, + [this](const TwitchUserInfo& userInfo) { + obs_log(LOG_INFO, "Twitch user info received for: %s", + userInfo.login.toUtf8().constData()); + + // Save user info to config manager + auto* configManager = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (configManager) { + configManager->setTwitchUserInfo(userInfo.id, userInfo.login, + userInfo.displayName, userInfo.profileImageUrl, + userInfo.email, userInfo.viewCount); + } + }); + + connect(m_twitchClient.get(), &OneSevenLiveTwitchClient::errorOccurred, this, + [](const QString& errorMessage) { + obs_log(LOG_ERROR, "Twitch API client error: %s", + errorMessage.toUtf8().constData()); + }); +} + +OneSevenLiveTwitchAuth::~OneSevenLiveTwitchAuth() { + stopPolling(); +} + +QString OneSevenLiveTwitchAuth::getAuthUrl(const QString& redirectUri) { + return TWITCH_DEVICE_AUTH_URL.arg(getClientId(), redirectUri, getScope(), getState()); +} + +QString OneSevenLiveTwitchAuth::getState() { + // Generate a 32-hex-character CSRF state if not present + if (m_state.isEmpty()) { + QByteArray bytes; + bytes.resize(16); // 128-bit random + for (int i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(QRandomGenerator::global()->bounded(256)); + } + m_state = QString::fromLatin1(bytes.toHex()); + } + return m_state; +} + +bool OneSevenLiveTwitchAuth::validateState(const QString& state) const { + return !m_state.isEmpty() && state == m_state; +} + +void OneSevenLiveTwitchAuth::startDeviceCodeFlow() { + if (m_isAuthorizing) { + obs_log(LOG_WARNING, "Twitch authorization already in progress"); + return; + } + + m_isAuthorizing = true; + m_wasCancelled = false; + emit authorizationStarted(); + + requestDeviceCode(); +} + +void OneSevenLiveTwitchAuth::cancelAuthorization() { + if (!m_isAuthorizing) { + return; + } + + m_wasCancelled = true; + m_isAuthorizing = false; + stopPolling(); + + emit authorizationCancelled(); +} + +bool OneSevenLiveTwitchAuth::hasValidToken() const { + return !m_accessToken.isEmpty(); +} + +void OneSevenLiveTwitchAuth::setTokens(const QString& accessToken, const QString& refreshToken) { + m_accessToken = accessToken; + m_refreshToken = refreshToken; +} + +void OneSevenLiveTwitchAuth::clearTokens() { + m_accessToken.clear(); + m_refreshToken.clear(); +} + +void OneSevenLiveTwitchAuth::requestDeviceCode() { + obs_log(LOG_INFO, "Requesting Twitch device code"); + + QUrlQuery query; + query.addQueryItem("client_id", getClientId()); + query.addQueryItem("scope", getScope()); + QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8(); + + RemoteTextThread* thread = new RemoteTextThread( + TWITCH_DEVICE_AUTH_URL.toStdString(), "application/x-www-form-urlencoded", + std::string(postData.constData(), postData.size()), + /*timeoutSec=*/15, + /*isImageRequest=*/false); + + connect(thread, &RemoteTextThread::Result, this, &OneSevenLiveTwitchAuth::onDeviceCodeResult); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveTwitchAuth::onDeviceCodeResult(const QString& text, const QString& error) { + if (m_wasCancelled) { + return; + } + + if (!error.isEmpty()) { + QString errorMsg = QString("Device code request failed: %1").arg(error); + obs_log(LOG_ERROR, "Twitch device code request error: %s", errorMsg.toUtf8().constData()); + emit authorizationFailed(errorMsg); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(text.toUtf8()); + QJsonObject json = doc.object(); + + if (json.contains("error")) { + QString err = json["error"].toString(); + QString errorDescription = json["error_description"].toString(); + QString errorMsg = QString("%1: %2").arg(err, errorDescription); + obs_log(LOG_ERROR, "Twitch device code error: %s", errorMsg.toUtf8().constData()); + emit authorizationFailed(errorMsg); + return; + } + + m_deviceCode = json["device_code"].toString(); + m_userCode = json["user_code"].toString(); + m_verificationUri = json["verification_uri"].toString(); + m_verificationUriComplete = json["verification_uri_complete"].toString(); + m_expiresIn = json["expires_in"].toInt(); + m_interval = json["interval"].toInt(); + + if (m_deviceCode.isEmpty() || m_userCode.isEmpty() || m_verificationUri.isEmpty()) { + emit authorizationFailed("Invalid device code response from Twitch"); + return; + } + + obs_log(LOG_INFO, "Twitch device code received successfully"); + emit deviceCodeReceived(m_userCode, m_verificationUri, m_verificationUriComplete); + + // Start polling for token + startPolling(); +} + +void OneSevenLiveTwitchAuth::startPolling() { + if (m_isPolling) { + return; + } + + m_isPolling = true; + m_remainingTime = m_expiresIn; + + m_pollingTimer->start(m_interval * 1000); // Convert to milliseconds + emit pollingStarted(m_interval); + + // Start the first poll immediately + pollForToken(); +} + +void OneSevenLiveTwitchAuth::stopPolling() { + if (!m_isPolling) { + return; + } + + m_isPolling = false; + m_pollingTimer->stop(); +} + +void OneSevenLiveTwitchAuth::pollForToken() { + if (m_wasCancelled || !m_isPolling) { + return; + } + + m_remainingTime -= m_interval; + emit pollingProgress(m_remainingTime); + + if (m_remainingTime <= 0) { + stopPolling(); + m_isAuthorizing = false; + emit authorizationFailed("Authorization timeout - device code expired"); + return; + } + + requestToken(); +} + +void OneSevenLiveTwitchAuth::requestToken() { + QUrlQuery query; + query.addQueryItem("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + query.addQueryItem("device_code", m_deviceCode); + query.addQueryItem("client_id", getClientId()); + QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8(); + + RemoteTextThread* thread = + new RemoteTextThread(TWITCH_TOKEN_URL.toStdString(), "application/x-www-form-urlencoded", + std::string(postData.constData(), postData.size()), + /*timeoutSec=*/15, + /*isImageRequest=*/false); + + connect(thread, &RemoteTextThread::Result, this, &OneSevenLiveTwitchAuth::onTokenResult); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveTwitchAuth::onTokenResult(const QString& text, const QString& error) { + if (m_wasCancelled) { + return; + } + if (!error.isEmpty()) { + QString errorMsg = QString("Token request failed: %1").arg(error); + obs_log(LOG_ERROR, "Twitch token request error: %s", errorMsg.toUtf8().constData()); + stopPolling(); + m_isAuthorizing = false; + emit authorizationFailed(errorMsg); + return; + } + + QJsonDocument doc = QJsonDocument::fromJson(text.toUtf8()); + QJsonObject json = doc.object(); + + if (json.contains("error")) { + QString err = json["error"].toString(); + if (err == "authorization_pending") { + return; // continue polling + } else if (err == "slow_down") { + m_interval += 5; + m_pollingTimer->setInterval(m_interval * 1000); + return; + } else if (err == "expired_token") { + stopPolling(); + m_isAuthorizing = false; + emit authorizationFailed("Device code expired - please restart authorization"); + return; + } else { + QString errorDescription = json["error_description"].toString(); + QString errorMsg = QString("%1: %2").arg(err, errorDescription); + obs_log(LOG_ERROR, "Twitch token request error: %s", errorMsg.toUtf8().constData()); + stopPolling(); + m_isAuthorizing = false; + emit authorizationFailed(errorMsg); + return; + } + } + + if (json.contains("access_token")) { + m_accessToken = json["access_token"].toString(); + m_refreshToken = json["refresh_token"].toString(); + obs_log(LOG_INFO, "Twitch authorization completed successfully"); + stopPolling(); + m_isAuthorizing = false; + + // Initialize Twitch client with the access token and fetch user info + if (m_twitchClient && !m_accessToken.isEmpty()) { + m_twitchClient->setAuthData(m_accessToken, getClientId()); + m_twitchClient->getCurrentUser(); + } + + emit authorizationCompleted(m_accessToken, m_refreshToken); + } else { + emit authorizationFailed("Invalid token response from Twitch"); + } +} + +QString OneSevenLiveTwitchAuth::getClientId() const { + return QString(TWITCH_API_CLIENT_ID); +} + +QString OneSevenLiveTwitchAuth::getScope() const { + return TWITCH_SCOPE; +} + +// getTwitchClient is defined inline in the header; no out-of-line definition needed. + +bool OneSevenLiveTwitchAuth::handleAuthorizationCallbackUrl(const QString& callbackUrl) { + QUrl url(callbackUrl); + if (!url.isValid()) { + obs_log(LOG_WARNING, "Twitch callback URL invalid: %s", callbackUrl.toUtf8().constData()); + return false; + } + + // If the callback contains error parameters, notify user and fail + const QUrlQuery query(url.query()); + const QString error = query.queryItemValue("error"); + const QString errorDescription = query.queryItemValue("error_description"); + if (!error.isEmpty()) { + const QString desc = errorDescription.isEmpty() ? error : errorDescription; + obs_log(LOG_WARNING, "Twitch authorization error: %s - %s", error.toUtf8().constData(), + desc.toUtf8().constData()); + QMessageBox::warning(nullptr, obs_module_text("Live.Common.Notice"), + QString("Twitch authorization failed: %1").arg(desc)); + emit authorizationFailed(desc); + return false; + } + + // Support implicit grant style: + // http://localhost:3000/#access_token=...&scope=...&state=...&token_type=bearer + const QString fragment = url.fragment(); + if (!fragment.isEmpty()) { + QUrlQuery fragQuery(fragment); + const QString accessToken = fragQuery.queryItemValue("access_token"); + const QString tokenType = fragQuery.queryItemValue("token_type"); + const QString scope = fragQuery.queryItemValue("scope"); + const QString state = fragQuery.queryItemValue("state"); + + if (accessToken.isEmpty()) { + obs_log(LOG_WARNING, "Twitch implicit callback missing 'access_token' in fragment"); + return false; + } + + // Validate CSRF state if present (warn only) + if (!state.isEmpty() && !validateState(state)) { + obs_log(LOG_WARNING, "Twitch callback state mismatch: expected=%s got=%s", + m_state.toUtf8().constData(), state.toUtf8().constData()); + } + + // Persist access token and fetched time + auto* cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (cfg && cfg->initialize()) { + const qint64 fetchedAt = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + // Save token (no refresh token in implicit flow) + if (!cfg->setTwitchTokens(accessToken, fetchedAt)) { + obs_log(LOG_ERROR, "Failed to save Twitch access token to config.ini"); + } + } else { + obs_log(LOG_ERROR, "ConfigManager not initialized; cannot persist Twitch token"); + } + + // Update local state and notify + setTokens(accessToken, ""); + m_callbackScope = scope; + obs_log(LOG_INFO, + "Twitch implicit callback parsed: access_token set, scope=%s token_type=%s", + m_callbackScope.toUtf8().constData(), tokenType.toUtf8().constData()); + + // Initialize Twitch client with the access token and fetch user info + if (m_twitchClient && !m_accessToken.isEmpty()) { + m_twitchClient->setAuthData(m_accessToken, getClientId()); + m_twitchClient->getCurrentUser(); + } + + emit authorizationCompleted(m_accessToken, m_refreshToken); + return true; + } + + // No fragment and no explicit error -> treat as unexpected format + obs_log(LOG_WARNING, "Twitch callback URL does not contain expected fragment or error: %s", + callbackUrl.toUtf8().constData()); + return false; +} diff --git a/src/17live/twitch/OneSevenLiveTwitchAuth.hpp b/src/17live/twitch/OneSevenLiveTwitchAuth.hpp new file mode 100644 index 0000000..f70805a --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchAuth.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include + +#include "../utility/RemoteTextThread.hpp" +#include "OneSevenLiveTwitchClient.hpp" + +/** + * Twitch authorization handler using device code flow + * Implements OAuth 2.0 device authorization grant for OBS plugin + */ +class OneSevenLiveTwitchAuth : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveTwitchAuth(QObject* parent = nullptr); + ~OneSevenLiveTwitchAuth(); + + QString getAuthUrl(const QString& redirectUri); + // CSRF state helpers + QString getState(); + bool validateState(const QString& state) const; + + // Device code flow steps + void startDeviceCodeFlow(); + void cancelAuthorization(); + + // Token management + bool hasValidToken() const; + + QString getAccessToken() const { + return m_accessToken; + } + + QString getRefreshToken() const { + return m_refreshToken; + } + + QString getUserCode() const { + return m_userCode; + } + + QString getVerificationUri() const { + return m_verificationUri; + } + + QString getVerificationUriComplete() const { + return m_verificationUriComplete; + } + + // Token operations + void setTokens(const QString& accessToken, const QString& refreshToken); + void clearTokens(); + + // Authorization callback handler: parse token/scope/state or errors from redirect URL + // Returns true on successful token parsing; false on error or unexpected format + bool handleAuthorizationCallbackUrl(const QString& callbackUrl); + + // Get Twitch API client instance + OneSevenLiveTwitchClient* getTwitchClient() { + return m_twitchClient.get(); + } + + signals: + void deviceCodeReceived(const QString& userCode, const QString& verificationUri, + const QString& verificationUriComplete); + void authorizationStarted(); + void authorizationCompleted(const QString& accessToken, const QString& refreshToken); + void authorizationFailed(const QString& error); + void authorizationCancelled(); + void pollingStarted(int intervalSeconds); + void pollingProgress(int remainingSeconds); + + private slots: + void onDeviceCodeResult(const QString& text, const QString& error); + void onTokenResult(const QString& text, const QString& error); + void pollForToken(); + + private: + void requestDeviceCode(); + void requestToken(); + void startPolling(); + void stopPolling(); + + QString getClientId() const; + QString getScope() const; + + // Internal helpers + QString m_state; + + QTimer* m_pollingTimer = nullptr; + + // Authorization state + QString m_deviceCode; + QString m_userCode; + QString m_verificationUri; + QString m_verificationUriComplete; + int m_expiresIn = 0; + int m_interval = 0; + int m_remainingTime = 0; + + // Token storage + QString m_accessToken; + QString m_refreshToken; + + // State flags + bool m_isAuthorizing = false; + bool m_isPolling = false; + bool m_wasCancelled = false; + + // Authorization code flow (via redirect) + QString m_authorizationCode; + QString m_callbackScope; + + // Twitch API client + std::unique_ptr m_twitchClient; + + public: + // Constants + static const QString PLATFORM; + static const QString TWITCH_DEVICE_AUTH_URL; + static const QString TWITCH_TOKEN_URL; + static const QString TWITCH_SCOPE; + static const QString TWITCH_CALLBACK_URI; +}; diff --git a/src/17live/twitch/OneSevenLiveTwitchChatClient.cpp b/src/17live/twitch/OneSevenLiveTwitchChatClient.cpp new file mode 100644 index 0000000..e088d02 --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchChatClient.cpp @@ -0,0 +1,594 @@ +#include "OneSevenLiveTwitchChatClient.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" +#include "multi-rtmp/OneSevenLiveMultiRtmpManager.hpp" +#include "plugin-support.h" +#include "websocket/OneSevenLiveWebsocketClient.hpp" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WebsocketUtils.hpp" +#include "websocket/WsMessage.hpp" + +const QString OneSevenLiveTwitchChatClient::TWITCH_IRC_SERVER = "wss://irc-ws.chat.twitch.tv:443"; +const int OneSevenLiveTwitchChatClient::DEFAULT_PING_INTERVAL = 60; // 1 minute +const int OneSevenLiveTwitchChatClient::DEFAULT_RECONNECT_DELAY = 5; // 5 seconds +const int OneSevenLiveTwitchChatClient::MAX_RECONNECT_ATTEMPTS = 5; +const int OneSevenLiveTwitchChatClient::STATUS_BROADCAST_INTERVAL = 10; +const int OneSevenLiveTwitchChatClient::LONG_RETRY_DELAY = 600; + +// Helper to convert TwitchMessageType to string +static const char* toString(TwitchMessageType type) { + switch (type) { + case TwitchMessageType::Chat: + return "chat"; + case TwitchMessageType::Notice: + return "notice"; + default: + return "chat"; + } +} + +// Helper to convert TwitchChatMessage to JSON +static nlohmann::json toJson(const TwitchChatMessage& msg) { + return {{"channel", msg.channel.toStdString()}, + {"username", msg.username.toStdString()}, + {"message", msg.message.toStdString()}, + {"timestamp", msg.timestamp.toString(Qt::ISODate).toStdString()}, + {"type", toString(msg.type)}}; +} + +OneSevenLiveTwitchChatClient::OneSevenLiveTwitchChatClient(QObject* parent) + : QObject(parent), + m_connected(false), + m_autoReconnect(true), + m_reconnectDelay(DEFAULT_RECONNECT_DELAY), + m_pingInterval(DEFAULT_PING_INTERVAL), + m_reconnectAttempts(0), + m_maxReconnectAttempts(MAX_RECONNECT_ATTEMPTS), + m_pingTimer(nullptr), + m_reconnectTimer(nullptr) { + // Set up ping timer + m_pingTimer = new QTimer(this); + m_pingTimer->setSingleShot(false); + connect(m_pingTimer, &QTimer::timeout, this, &OneSevenLiveTwitchChatClient::onPingTimeout); + + // Set up reconnect timer + m_reconnectTimer = new QTimer(this); + m_reconnectTimer->setSingleShot(true); + connect(m_reconnectTimer, &QTimer::timeout, this, + &OneSevenLiveTwitchChatClient::attemptReconnect); + + m_statusTimer = new QTimer(this); + m_statusTimer->setSingleShot(false); + m_statusTimer->setInterval(STATUS_BROADCAST_INTERVAL * 1000); + connect(m_statusTimer, &QTimer::timeout, this, &OneSevenLiveTwitchChatClient::onStatusTimer); + m_statusTimer->start(); +} + +OneSevenLiveTwitchChatClient::~OneSevenLiveTwitchChatClient() { + disconnectFromChat(); + stopPingTimer(); + if (m_statusTimer) { + m_statusTimer->stop(); + } + + if (m_reconnectTimer) { + m_reconnectTimer->stop(); + m_reconnectTimer->deleteLater(); + } +} + +void OneSevenLiveTwitchChatClient::connectToChat(const QString& username, + const QString& oauthToken) { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + obs_log(LOG_INFO, "[Twitch Chat Client] Skipping connect: shutting down"); + return; + } + if (m_connected) { + obs_log(LOG_INFO, "[Twitch Chat Client] Already connected to Twitch chat"); + return; + } + if (m_connecting) { + obs_log(LOG_INFO, "[Twitch Chat Client] Connect in progress, skip new request"); + return; + } + + m_username = username; + m_oauthToken = oauthToken; + m_reconnectAttempts = 0; + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + obs_log(LOG_INFO, "[Twitch Chat Client] Twitch chat connect requested: isLive=%d username=%s", + isLive ? 1 : 0, username.toUtf8().constData()); + obs_log(LOG_INFO, "[Twitch Chat Client] Connecting to Twitch chat server: %s", + TWITCH_IRC_SERVER.toUtf8().constData()); + m_connecting = true; + connectWebSocket(); +} + +void OneSevenLiveTwitchChatClient::disconnectFromChat() { + if (!m_connected) { + return; + } + + obs_log(LOG_INFO, "Disconnecting from Twitch chat"); + m_autoReconnect = false; // Prevent auto-reconnect on manual disconnect + + disconnectWebSocket(); + + stopPingTimer(); + m_joinedChannels.clear(); +} + +bool OneSevenLiveTwitchChatClient::isConnected() const { + return m_connected; +} + +void OneSevenLiveTwitchChatClient::joinChannel(const QString& channel) { + if (!m_connected) { + obs_log(LOG_WARNING, "Cannot join channel: not connected to chat"); + return; + } + + QString normalizedChannel = normalizeChannelName(channel); + if (isChannelJoined(normalizedChannel)) { + obs_log(LOG_INFO, "Already joined channel: %s", normalizedChannel.toUtf8().constData()); + return; + } + + sendIRCCommand("JOIN", "#" + normalizedChannel); + m_joinedChannels.append(normalizedChannel); + + obs_log(LOG_INFO, "Joining channel: %s", normalizedChannel.toUtf8().constData()); +} + +void OneSevenLiveTwitchChatClient::leaveChannel(const QString& channel) { + if (!m_connected) { + obs_log(LOG_WARNING, "Cannot leave channel: not connected to chat"); + return; + } + + QString normalizedChannel = normalizeChannelName(channel); + if (!isChannelJoined(normalizedChannel)) { + obs_log(LOG_INFO, "Not in channel: %s", normalizedChannel.toUtf8().constData()); + return; + } + + sendIRCCommand("PART", "#" + normalizedChannel); + m_joinedChannels.removeOne(normalizedChannel); + + obs_log(LOG_INFO, "Leaving channel: %s", normalizedChannel.toUtf8().constData()); + emit channelLeft(normalizedChannel); +} + +void OneSevenLiveTwitchChatClient::leaveAllChannels() { + for (const QString& channel : m_joinedChannels) { + leaveChannel(channel); + } +} + +QVector OneSevenLiveTwitchChatClient::getJoinedChannels() const { + return m_joinedChannels; +} + +void OneSevenLiveTwitchChatClient::sendMessage(const QString& channel, const QString& message) { + if (!m_connected) { + obs_log(LOG_WARNING, "Cannot send message: not connected to chat"); + return; + } + + QString normalizedChannel = normalizeChannelName(channel); + if (!isChannelJoined(normalizedChannel)) { + obs_log(LOG_WARNING, "Cannot send message: not in channel %s", + normalizedChannel.toUtf8().constData()); + return; + } + + sendIRCCommand("PRIVMSG", "#" + normalizedChannel + " :" + message); + obs_log(LOG_INFO, "Sending message to %s: %s", normalizedChannel.toUtf8().constData(), + message.toUtf8().constData()); +} + +void OneSevenLiveTwitchChatClient::sendWhisper(const QString& username, const QString& message) { + if (!m_connected) { + obs_log(LOG_WARNING, "Cannot send whisper: not connected to chat"); + return; + } + + // Note: Whisper functionality requires special permissions and may not work with all tokens + sendIRCCommand("PRIVMSG", "#jtv :/w " + username + " " + message); + obs_log(LOG_INFO, "Sending whisper to %s: %s", username.toUtf8().constData(), + message.toUtf8().constData()); +} + +void OneSevenLiveTwitchChatClient::setAutoReconnect(bool enabled) { + m_autoReconnect = enabled; +} + +void OneSevenLiveTwitchChatClient::setReconnectDelay(int seconds) { + m_reconnectDelay = seconds; +} + +void OneSevenLiveTwitchChatClient::setPingInterval(int seconds) { + m_pingInterval = seconds; +} + +// WebSocket event handlers +void OneSevenLiveTwitchChatClient::onWebSocketMessage(const std::string& message) { + QString qMessage = QString::fromStdString(message); + parseIRCMessage(qMessage); + + if (qMessage.contains(" PONG ") || qMessage.startsWith(":tmi.twitch.tv PONG")) { + obs_log(LOG_DEBUG, "Received PONG from Twitch chat"); + } else { + obs_log(LOG_DEBUG, "Received message from Twitch chat: %s", qMessage.toUtf8().constData()); + } + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventTwitchChatMessage), + nlohmann::json{{"raw", qMessage.toStdString()}}); +} + +void OneSevenLiveTwitchChatClient::onWebSocketOpen() { + obs_log(LOG_INFO, "[Twitch Chat Client] Connected to Twitch chat server"); + m_connected = true; + m_connecting = false; + m_reconnectAttempts = 0; + m_lastPongTs = QDateTime::currentDateTime(); + m_joinedChannels.clear(); + + // Request capabilities + requestCapabilities(); + + // Authenticate + authenticate(); + + // Start ping timer + startPingTimer(); + + emit connected(); + auto& core = OneSevenLiveCoreManager::getInstance(); + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + const char* st = (m_connected && isLive) ? "connected" : "break"; + obs_log(LOG_INFO, + "Broadcast EventTwitchChatConnected on open: username=%s status=%s isLive=%d " + "m_connected=%d", + m_username.toUtf8().constData(), st, isLive ? 1 : 0, m_connected ? 1 : 0); + core.enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventTwitchChatConnected), + nlohmann::json{{"username", m_username.toStdString()}, {"status", st}}); + + QString channelToJoin = m_targetChannel.isEmpty() ? m_username : m_targetChannel; + if (!channelToJoin.isEmpty()) { + joinChannel(channelToJoin); + } +} + +void OneSevenLiveTwitchChatClient::onWebSocketClose() { + obs_log(LOG_INFO, "[Twitch Chat Client] Disconnected from Twitch chat server"); + m_connected = false; + m_connecting = false; + stopPingTimer(); + + emit disconnected(); + auto& core = OneSevenLiveCoreManager::getInstance(); + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + const char* st = (m_connected && isLive) ? "connected" : "break"; + obs_log(LOG_INFO, + "Broadcast EventTwitchChatConnected on close: username=%s status=%s isLive=%d " + "m_connected=%d", + m_username.toUtf8().constData(), st, isLive ? 1 : 0, m_connected ? 1 : 0); + if (!OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + core.enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventTwitchChatConnected), + nlohmann::json{{"username", m_username.toStdString()}, {"status", st}}); + } else { + obs_log(LOG_INFO, + "[Twitch Chat Client] Suppress EventTwitchChatConnected on close due to shutdown"); + } + + if (m_autoReconnect && !OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + scheduleReconnect(); + } +} + +void OneSevenLiveTwitchChatClient::onWebSocketError(const std::string& error) { + QString errorMsg = QString::fromStdString(error); + obs_log(LOG_WARNING, "[Twitch Chat Client] WebSocket error: %s", errorMsg.toUtf8().constData()); + m_connecting = false; + emit connectionError(errorMsg); + + if (m_autoReconnect) { + scheduleReconnect(); + } +} + +void OneSevenLiveTwitchChatClient::onPingTimeout() { + if (m_connected) { + sendRawMessage("PING :tmi.twitch.tv"); + } +} + +void OneSevenLiveTwitchChatClient::attemptReconnect() { + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + if (m_connected || m_connecting || !m_autoReconnect || !isLive) { + return; + } + + m_reconnectAttempts++; + obs_log(LOG_INFO, "Attempting reconnection %d of %d", m_reconnectAttempts, + m_maxReconnectAttempts); + + emit reconnecting(m_reconnectAttempts); + + // Reconnect with saved credentials + if (!m_username.isEmpty() && !m_oauthToken.isEmpty()) { + connectToChat(m_username, m_oauthToken); + } +} + +// IRC protocol implementation +void OneSevenLiveTwitchChatClient::sendRawMessage(const QString& message) { + if (m_connected && m_client && m_client->isConnected()) { + sendWebSocketMessage(message.toStdString()); + int level = message.startsWith("PING ") ? LOG_DEBUG : LOG_INFO; + obs_log(level, "IRC ->: %s", message.toUtf8().constData()); + } +} + +void OneSevenLiveTwitchChatClient::sendIRCCommand(const QString& command, + const QString& parameters) { + sendRawMessage(command + " " + parameters); +} + +void OneSevenLiveTwitchChatClient::authenticate() { + if (!m_oauthToken.isEmpty() && !m_username.isEmpty()) { + // Send OAuth authentication + if (m_oauthToken.startsWith("oauth:")) { + sendRawMessage("PASS " + m_oauthToken); + } else { + sendRawMessage("PASS oauth:" + m_oauthToken); + } + sendRawMessage("NICK " + m_username); + obs_log(LOG_INFO, "Authenticating as %s", m_username.toUtf8().constData()); + } +} + +void OneSevenLiveTwitchChatClient::requestCapabilities() { + // Request Twitch-specific capabilities + sendRawMessage("CAP REQ :twitch.tv/tags"); + sendRawMessage("CAP REQ :twitch.tv/commands"); + sendRawMessage("CAP REQ :twitch.tv/membership"); + obs_log(LOG_INFO, "Requesting Twitch capabilities"); +} + +void OneSevenLiveTwitchChatClient::startPingTimer() { + if (m_pingTimer) { + m_pingTimer->start(m_pingInterval * 1000); + } +} + +void OneSevenLiveTwitchChatClient::stopPingTimer() { + if (m_pingTimer) { + m_pingTimer->stop(); + } +} + +void OneSevenLiveTwitchChatClient::parseIRCMessage(const QString& rawMessage) { + obs_log(LOG_DEBUG, "IRC <-: %s", rawMessage.toUtf8().constData()); + + QString message = rawMessage; + QString prefix; + QString command; + QString parameters; + + if (message.startsWith('@')) { + int tagEnd = message.indexOf(' '); + if (tagEnd != -1) { + message = message.mid(tagEnd + 1); + } + } + + if (message.startsWith(':')) { + int prefixEnd = message.indexOf(' '); + if (prefixEnd != -1) { + prefix = message.mid(1, prefixEnd - 1); + message = message.mid(prefixEnd + 1); + } + } + + int commandEnd = message.indexOf(' '); + if (commandEnd != -1) { + command = message.left(commandEnd); + parameters = message.mid(commandEnd + 1); + } else { + command = message; + } + + if (command == "PING") { + sendRawMessage("PONG " + parameters); + return; + } + if (command == "PONG") { + m_lastPongTs = QDateTime::currentDateTime(); + obs_log(LOG_DEBUG, "IRC PONG acknowledged"); + return; + } + if (command == "001") { + emit reconnected(); + return; + } + if (command == "JOIN") { + QString channel = parameters.mid(1); + QString username = extractUsernameFromPrefix(prefix); + if (username == m_username) { + emit channelJoined(channel); + } else { + TwitchChatUser user; + user.username = username; + emit userJoined(channel, user); + } + return; + } + if (command == "PART") { + QString channel = parameters.mid(1); + QString username = extractUsernameFromPrefix(prefix); + if (username == m_username) { + m_joinedChannels.removeOne(channel); + emit channelLeft(channel); + } else { + emit userLeft(channel, username); + } + return; + } + if (command == "PRIVMSG") { + TwitchChatMessage chatMessage = parseChatMessage(rawMessage, prefix); + chatMessage.type = TwitchMessageType::Chat; + emit messageReceived(chatMessage); + return; + } + if (command == "NOTICE") { + QString channel = parameters.section(' ', 0, 0).mid(1); + QString noticeMsg = parameters.section(' ', 1); + if (noticeMsg.startsWith(':')) { + noticeMsg = noticeMsg.mid(1); + } + emit noticeReceived(channel, noticeMsg); + return; + } +} + +TwitchChatMessage OneSevenLiveTwitchChatClient::parseChatMessage(const QString& rawMessage, + const QString& prefix) { + TwitchChatMessage message; + message.timestamp = QDateTime::currentDateTime(); + + message.username = extractUsernameFromPrefix(prefix); + message.displayName = message.username; + + QString params = rawMessage; + int channelStart = params.indexOf('#'); + int channelEnd = params.indexOf(" :"); + if (channelStart != -1 && channelEnd != -1 && channelEnd > channelStart) { + message.channel = params.mid(channelStart + 1, channelEnd - channelStart - 1); + message.message = params.mid(channelEnd + 2); + } + + return message; +} + +QString OneSevenLiveTwitchChatClient::extractUsernameFromPrefix(const QString& prefix) { + if (prefix.isEmpty()) { + return QString(); + } + + int exclamation = prefix.indexOf('!'); + if (exclamation != -1) { + return prefix.left(exclamation); + } + + return prefix; +} + +bool OneSevenLiveTwitchChatClient::isChannelJoined(const QString& channel) const { + QString normalizedChannel = normalizeChannelName(channel); + return m_joinedChannels.contains(normalizedChannel); +} + +QString OneSevenLiveTwitchChatClient::normalizeChannelName(const QString& channel) const { + QString normalized = channel.toLower(); + if (normalized.startsWith('#')) { + normalized = normalized.mid(1); + } + return normalized; +} + +void OneSevenLiveTwitchChatClient::scheduleReconnect() { + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + if (!isLive) + return; + if (m_reconnectTimer && !m_reconnectTimer->isActive()) { + int delay = m_reconnectDelay; + if (m_reconnectAttempts >= m_maxReconnectAttempts) { + delay = LONG_RETRY_DELAY; + m_reconnectAttempts = 0; + } + obs_log(LOG_INFO, "Scheduling reconnection in %d seconds", delay); + m_reconnectTimer->start(delay * 1000); + } +} + +void OneSevenLiveTwitchChatClient::resetReconnectAttempts() { + m_reconnectAttempts = 0; +} + +void OneSevenLiveTwitchChatClient::onStatusTimer() { + if (OneSevenLiveCoreManager::getInstance().isShuttingDown()) { + obs_log(LOG_INFO, "[Twitch Chat Client] StatusTimer: suppress broadcasts during shutdown"); + return; + } + obs_log(LOG_DEBUG, "[Twitch Chat Client] StatusTimer broadcast: username=%s status=%s", + m_username.toUtf8().constData(), m_connected ? "connected" : "break"); + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventTwitchChatConnected), + nlohmann::json{{"username", m_username.toStdString()}, + {"status", m_connected ? "connected" : "break"}}); + + const bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("Twitch"); + if (!isLive && m_connected) { + disconnectWebSocket(); + } else if (isLive && !m_connected && !m_connecting && !m_username.isEmpty() && + !m_oauthToken.isEmpty()) { + obs_log(LOG_INFO, + "[Twitch Chat Client] StatusTimer: attempting connect as live=%d username=%s", + isLive ? 1 : 0, m_username.toUtf8().constData()); + connectToChat(m_username, m_oauthToken); + } + + if (m_connected) { + QDateTime now = QDateTime::currentDateTime(); + if (m_lastPongTs.isValid()) { + int seconds = m_lastPongTs.secsTo(now); + if (seconds > 2 * m_pingInterval) { + obs_log(LOG_INFO, "No PONG within %d seconds, reconnecting", seconds); + disconnectWebSocket(); + if (m_autoReconnect) { + scheduleReconnect(); + } + } + } + } +} + +void OneSevenLiveTwitchChatClient::connectWebSocket() { + obs_log(LOG_INFO, "[Twitch Chat Client] Connecting to Twitch chat server: %s", + TWITCH_IRC_SERVER.toUtf8().constData()); + m_client = std::make_unique(this); + m_client->setOpenCallback([this]() { onWebSocketOpen(); }); + m_client->setMessageCallback([this](const std::string& m) { onWebSocketMessage(m); }); + m_client->setCloseCallback([this]() { onWebSocketClose(); }); + m_client->setErrorCallback([this](const std::string& e) { onWebSocketError(e); }); + m_client->connectUrl(TWITCH_IRC_SERVER); +} + +void OneSevenLiveTwitchChatClient::disconnectWebSocket() { + obs_log(LOG_INFO, "[Twitch Chat Client] Disconnecting WebSocket client from Twitch"); + m_connecting = false; + if (m_client) { + m_client->disconnectAsync(); + } +} + +void OneSevenLiveTwitchChatClient::sendWebSocketMessage(const std::string& message) { + if (m_client && m_client->isConnected()) { + m_client->sendText(QString::fromStdString(message)); + } else { + obs_log(LOG_WARNING, "Cannot send WebSocket message: not connected"); + } +} diff --git a/src/17live/twitch/OneSevenLiveTwitchChatClient.hpp b/src/17live/twitch/OneSevenLiveTwitchChatClient.hpp new file mode 100644 index 0000000..892ce34 --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchChatClient.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class OneSevenLiveWebsocketClient; + +namespace ably {} +class OneSevenLiveStreamManager; +enum class OneSevenLiveStreamingStatus; + +enum class TwitchMessageType { + Chat, + Subscription, + Resubscription, + GiftSubscription, + Raid, + Host, + Whisper, + Notice, + UserNotice, + RoomState, + UserState, + GlobalUserState, + Unknown +}; + +struct TwitchChatMessage { + QString id; + QString channel; + QString username; + QString displayName; + QString message; + QString userId; + QString color; + QDateTime timestamp; + bool isModerator; + bool isSubscriber; + bool isTurbo; + bool isFirstMessage; + bool isReturningChatter; + int bits; + QString emotes; + QString badges; + TwitchMessageType type; + + TwitchChatMessage() + : isModerator(false), + isSubscriber(false), + isTurbo(false), + isFirstMessage(false), + isReturningChatter(false), + bits(0), + type(TwitchMessageType::Chat) {} +}; + +struct TwitchChatUser { + QString userId; + QString username; + QString displayName; + QString color; + bool isModerator; + bool isSubscriber; + bool isTurbo; + + TwitchChatUser() : isModerator(false), isSubscriber(false), isTurbo(false) {} +}; + +class OneSevenLiveTwitchChatClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveTwitchChatClient(QObject* parent = nullptr); + ~OneSevenLiveTwitchChatClient(); + + // Connection management + void connectToChat(const QString& username, const QString& oauthToken); + void disconnectFromChat(); + bool isConnected() const; + + // Channel management + void joinChannel(const QString& channel); + void leaveChannel(const QString& channel); + void leaveAllChannels(); + QVector getJoinedChannels() const; + + // Message sending + void sendMessage(const QString& channel, const QString& message); + void sendWhisper(const QString& username, const QString& message); + + // Configuration + void setAutoReconnect(bool enabled); + void setReconnectDelay(int seconds); + void setPingInterval(int seconds); + void setTargetChannel(const QString& channel); + + signals: + void connected(); + void disconnected(); + void connectionError(const QString& error); + void messageReceived(const TwitchChatMessage& message); + void userJoined(const QString& channel, const TwitchChatUser& user); + void userLeft(const QString& channel, const QString& username); + void channelJoined(const QString& channel); + void channelLeft(const QString& channel); + void subscriptionReceived(const TwitchChatMessage& message); + void raidReceived(const QString& channel, const QString& raider, int viewerCount); + void noticeReceived(const QString& channel, const QString& message); + void reconnecting(int attempt); + void reconnected(); + + private slots: + void onWebSocketMessage(const std::string& message); + void onWebSocketOpen(); + void onWebSocketClose(); + void onWebSocketError(const std::string& error); + void onPingTimeout(); + void attemptReconnect(); + void onStatusTimer(); + + private: + void sendRawMessage(const QString& message); + void sendIRCCommand(const QString& command, const QString& parameters); + void authenticate(); + void requestCapabilities(); + void startPingTimer(); + void stopPingTimer(); + + // Message parsing + void parseIRCMessage(const QString& rawMessage); + TwitchChatMessage parseChatMessage(const QString& rawMessage, const QString& prefix); + QString extractUsernameFromPrefix(const QString& prefix); + + // Channel management + bool isChannelJoined(const QString& channel) const; + QString normalizeChannelName(const QString& channel) const; + + // Reconnection logic + void scheduleReconnect(); + void resetReconnectAttempts(); + + void connectWebSocket(); + void disconnectWebSocket(); + void sendWebSocketMessage(const std::string& message); + + // Connection state + bool m_connected = false; + bool m_connecting{false}; + QString m_username; + QString m_oauthToken; + QString m_targetChannel; + QVector m_joinedChannels; + std::unique_ptr m_client; + + // Configuration + bool m_autoReconnect = false; + int m_reconnectDelay = 0; + int m_pingInterval = 0; + int m_reconnectAttempts = 0; + int m_maxReconnectAttempts = 0; + + // Timers + QTimer* m_pingTimer = nullptr; + QTimer* m_reconnectTimer = nullptr; + QTimer* m_statusTimer = nullptr; + QDateTime m_lastPongTs; + + // Constants + static const QString TWITCH_IRC_SERVER; + static const int DEFAULT_PING_INTERVAL; + static const int DEFAULT_RECONNECT_DELAY; + static const int MAX_RECONNECT_ATTEMPTS; + static const int STATUS_BROADCAST_INTERVAL; + static const int LONG_RETRY_DELAY; + + bool m_streamSignalConnected{false}; +}; diff --git a/src/17live/twitch/OneSevenLiveTwitchClient.cpp b/src/17live/twitch/OneSevenLiveTwitchClient.cpp new file mode 100644 index 0000000..4c3558c --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchClient.cpp @@ -0,0 +1,374 @@ +#include "OneSevenLiveTwitchClient.hpp" + +#include + +#include +#include + +#include "../utility/RemoteTextThread.hpp" +#include "plugin-support.h" + +// API endpoints +const QString OneSevenLiveTwitchClient::TWITCH_HELIX_API_BASE = "https://api.twitch.tv/helix"; +const QString OneSevenLiveTwitchClient::TWITCH_USERS_ENDPOINT = "/users"; +const QString OneSevenLiveTwitchClient::TWITCH_CHANNELS_ENDPOINT = "/channels"; +const QString OneSevenLiveTwitchClient::TWITCH_STREAM_KEY_ENDPOINT = "/streams/key"; +const QString OneSevenLiveTwitchClient::TWITCH_RTMP_SERVER = + "rtmp://ingest.global-contribute.live-video.net/app/{stream_key}"; + +OneSevenLiveTwitchClient::OneSevenLiveTwitchClient(QObject* parent) : QObject(parent) { + obs_log(LOG_INFO, "TwitchClient initialized"); +} + +OneSevenLiveTwitchClient::~OneSevenLiveTwitchClient() { + obs_log(LOG_INFO, "TwitchClient destroyed"); +} + +void OneSevenLiveTwitchClient::setAuthData(const QString& accessToken, const QString& clientId) { + m_accessToken = accessToken; + m_clientId = clientId; + obs_log(LOG_INFO, "TwitchClient auth data set"); +} + +bool OneSevenLiveTwitchClient::hasValidAuth() const { + return !m_accessToken.isEmpty() && !m_clientId.isEmpty(); +} + +void OneSevenLiveTwitchClient::getCurrentUser() { + if (!hasValidAuth()) { + emit errorOccurred("Missing authentication data"); + return; + } + + obs_log(LOG_INFO, "Fetching current Twitch user info"); + makeApiRequest(TWITCH_USERS_ENDPOINT); +} + +void OneSevenLiveTwitchClient::getUserById(const QString& userId) { + if (!hasValidAuth()) { + emit errorOccurred("Missing authentication data"); + return; + } + + if (userId.isEmpty()) { + emit errorOccurred("User ID cannot be empty"); + return; + } + + obs_log(LOG_INFO, "Fetching Twitch user info by ID: %s", userId.toUtf8().constData()); + makeApiRequest(TWITCH_USERS_ENDPOINT, QString("id=%1").arg(userId)); +} + +void OneSevenLiveTwitchClient::getUserByLogin(const QString& login) { + if (!hasValidAuth()) { + emit errorOccurred("Missing authentication data"); + return; + } + + if (login.isEmpty()) { + emit errorOccurred("Login cannot be empty"); + return; + } + + obs_log(LOG_INFO, "Fetching Twitch user info by login: %s", login.toUtf8().constData()); + makeApiRequest(TWITCH_USERS_ENDPOINT, QString("login=%1").arg(login)); +} + +void OneSevenLiveTwitchClient::getChannelInformation(const QString& broadcasterId) { + if (!hasValidAuth()) { + emit errorOccurred("Missing authentication data"); + return; + } + + if (broadcasterId.isEmpty()) { + emit errorOccurred("Broadcaster ID cannot be empty"); + return; + } + + obs_log(LOG_INFO, "Fetching Twitch channel info for broadcaster: %s", + broadcasterId.toUtf8().constData()); + + QString url = TWITCH_HELIX_API_BASE + TWITCH_CHANNELS_ENDPOINT; + QString fullUrl = QString("%1?broadcaster_id=%2").arg(url, broadcasterId); + + // Build required headers + std::vector headers; + headers.push_back(std::string("Authorization: ") + + QString("Bearer %1").arg(m_accessToken).toStdString()); + headers.push_back(std::string("Client-Id: ") + m_clientId.toStdString()); + + RemoteTextThread* thread = + new RemoteTextThread(fullUrl.toStdString(), std::move(headers), "application/json", + "", // No post data for GET request + /*timeoutSec=*/15, + /*isImageRequest=*/false); + + connect(thread, &RemoteTextThread::Result, this, + &OneSevenLiveTwitchClient::onChannelInfoResult); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveTwitchClient::getStreamKey(const QString& broadcasterId) { + if (!hasValidAuth()) { + emit errorOccurred("Missing authentication data"); + return; + } + + if (broadcasterId.isEmpty()) { + emit errorOccurred("Broadcaster ID cannot be empty"); + return; + } + + obs_log(LOG_INFO, "Fetching Twitch stream key for broadcaster: %s", + broadcasterId.toUtf8().constData()); + + QString url = TWITCH_HELIX_API_BASE + TWITCH_STREAM_KEY_ENDPOINT; + QString fullUrl = QString("%1?broadcaster_id=%2").arg(url, broadcasterId); + + std::vector headers; + headers.push_back(std::string("Authorization: ") + + QString("Bearer %1").arg(m_accessToken).toStdString()); + headers.push_back(std::string("Client-Id: ") + m_clientId.toStdString()); + + RemoteTextThread* thread = + new RemoteTextThread(fullUrl.toStdString(), std::move(headers), "application/json", "", + /*timeoutSec=*/15, + /*isImageRequest=*/false); + + connect(thread, &RemoteTextThread::Result, this, &OneSevenLiveTwitchClient::onStreamKeyResult); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveTwitchClient::makeApiRequest(const QString& endpoint, const QString& query) { + QString url = TWITCH_HELIX_API_BASE + endpoint; + if (!query.isEmpty()) { + url += "?" + query; + } + + // Build required headers + std::vector headers; + headers.push_back(std::string("Authorization: ") + + QString("Bearer %1").arg(m_accessToken).toStdString()); + headers.push_back(std::string("Client-Id: ") + m_clientId.toStdString()); + + RemoteTextThread* thread = + new RemoteTextThread(url.toStdString(), std::move(headers), "application/json", + "", // No post data for GET request + /*timeoutSec=*/15, + /*isImageRequest=*/false); + + connect(thread, &RemoteTextThread::Result, this, &OneSevenLiveTwitchClient::onUserInfoResult); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveTwitchClient::onUserInfoResult(const QString& text, const QString& error) { + if (!error.isEmpty()) { + QString errorMsg = QString("Twitch API request failed: %1").arg(error); + obs_log(LOG_ERROR, "Twitch user info request error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + try { + nlohmann::json json = nlohmann::json::parse(text.toStdString()); + + if (json.contains("error")) { + std::string err = json["error"].get(); + std::string errorDescription = json.value("message", ""); + QString errorMsg = + QString("Twitch API error: %1 - %2") + .arg(QString::fromStdString(err), QString::fromStdString(errorDescription)); + obs_log(LOG_ERROR, "Twitch API error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + if (!json.contains("data") || !json["data"].is_array()) { + QString errorMsg = "Invalid Twitch API response format"; + obs_log(LOG_ERROR, "Twitch API response missing data array"); + emit errorOccurred(errorMsg); + return; + } + + auto& dataArray = json["data"]; + if (dataArray.empty()) { + QString errorMsg = "No user data found"; + obs_log(LOG_WARNING, "Twitch API returned empty user data"); + emit errorOccurred(errorMsg); + return; + } + + auto& userObj = dataArray[0]; + TwitchUserInfo userInfo = parseUserInfo(userObj); + + // Cache the user info + m_cachedUserInfo = userInfo; + + obs_log(LOG_INFO, "Twitch user info retrieved successfully for: %s", + userInfo.login.toUtf8().constData()); + emit userInfoReceived(userInfo); + } catch (const nlohmann::json::exception& e) { + QString errorMsg = QString("Failed to parse Twitch API response: %1") + .arg(QString::fromStdString(e.what())); + obs_log(LOG_ERROR, "Twitch user info parse error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } +} + +void OneSevenLiveTwitchClient::onChannelInfoResult(const QString& text, const QString& error) { + if (!error.isEmpty()) { + QString errorMsg = QString("Twitch channel info request failed: %1").arg(error); + obs_log(LOG_ERROR, "Twitch channel info request error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + try { + nlohmann::json json = nlohmann::json::parse(text.toStdString()); + + if (json.contains("error")) { + std::string err = json["error"].get(); + std::string errorDescription = json.value("message", ""); + QString errorMsg = + QString("Twitch API error: %1 - %2") + .arg(QString::fromStdString(err), QString::fromStdString(errorDescription)); + obs_log(LOG_ERROR, "Twitch API error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + if (!json.contains("data") || !json["data"].is_array()) { + QString errorMsg = "Invalid Twitch API response format"; + obs_log(LOG_ERROR, "Twitch API response missing data array"); + emit errorOccurred(errorMsg); + return; + } + + auto& dataArray = json["data"]; + if (dataArray.empty()) { + QString errorMsg = "No channel data found"; + obs_log(LOG_WARNING, "Twitch API returned empty channel data"); + emit errorOccurred(errorMsg); + return; + } + + auto& channelObj = dataArray[0]; + TwitchChannelInfo channelInfo = parseChannelInfo(channelObj); + + // Cache the channel info + m_cachedChannelInfo = channelInfo; + + obs_log(LOG_INFO, "Twitch channel info retrieved successfully for: %s", + channelInfo.broadcasterName.toUtf8().constData()); + emit channelInfoReceived(channelInfo); + } catch (const nlohmann::json::exception& e) { + QString errorMsg = QString("Failed to parse Twitch API response: %1") + .arg(QString::fromStdString(e.what())); + obs_log(LOG_ERROR, "Twitch channel info parse error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } +} + +void OneSevenLiveTwitchClient::onStreamKeyResult(const QString& text, const QString& error) { + if (!error.isEmpty()) { + QString errorMsg = QString("Twitch stream key request failed: %1").arg(error); + obs_log(LOG_ERROR, "Twitch stream key request error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + try { + nlohmann::json json = nlohmann::json::parse(text.toStdString()); + + if (json.contains("error")) { + std::string err = json["error"].get(); + std::string errorDescription = json.value("message", ""); + QString errorMsg = + QString("Twitch API error: %1 - %2") + .arg(QString::fromStdString(err), QString::fromStdString(errorDescription)); + obs_log(LOG_ERROR, "Twitch API error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } + + if (!json.contains("data") || !json["data"].is_array()) { + QString errorMsg = "Invalid Twitch API response format"; + obs_log(LOG_ERROR, "Twitch API response missing data array for stream key"); + emit errorOccurred(errorMsg); + return; + } + + auto& dataArray = json["data"]; + if (dataArray.empty()) { + QString errorMsg = "No stream key data found"; + obs_log(LOG_WARNING, "Twitch API returned empty stream key data"); + emit errorOccurred(errorMsg); + return; + } + + auto& obj = dataArray[0]; + const std::string key = obj.value("stream_key", std::string()); + if (key.empty()) { + QString errorMsg = "Stream key missing in Twitch response"; + obs_log(LOG_ERROR, "Twitch stream key missing in response object"); + emit errorOccurred(errorMsg); + return; + } + + QString streamKey = QString::fromStdString(key); + obs_log(LOG_INFO, "Twitch stream key retrieved successfully: %s", + streamKey.toUtf8().constData()); + emit streamKeyReceived(streamKey); + } catch (const nlohmann::json::exception& e) { + QString errorMsg = QString("Failed to parse Twitch API response: %1") + .arg(QString::fromStdString(e.what())); + obs_log(LOG_ERROR, "Twitch stream key parse error: %s", errorMsg.toUtf8().constData()); + emit errorOccurred(errorMsg); + return; + } +} + +TwitchUserInfo OneSevenLiveTwitchClient::parseUserInfo(const nlohmann::json& userObj) { + TwitchUserInfo userInfo; + + userInfo.id = QString::fromStdString(userObj.value("id", "")); + userInfo.login = QString::fromStdString(userObj.value("login", "")); + userInfo.displayName = QString::fromStdString(userObj.value("display_name", "")); + userInfo.type = QString::fromStdString(userObj.value("type", "")); + userInfo.broadcasterType = QString::fromStdString(userObj.value("broadcaster_type", "")); + userInfo.description = QString::fromStdString(userObj.value("description", "")); + userInfo.profileImageUrl = QString::fromStdString(userObj.value("profile_image_url", "")); + userInfo.offlineImageUrl = QString::fromStdString(userObj.value("offline_image_url", "")); + userInfo.viewCount = userObj.value("view_count", 0); + userInfo.email = QString::fromStdString(userObj.value("email", "")); + userInfo.createdAt = QString::fromStdString(userObj.value("created_at", "")); + + return userInfo; +} + +TwitchChannelInfo OneSevenLiveTwitchClient::parseChannelInfo(const nlohmann::json& channelObj) { + TwitchChannelInfo channelInfo; + + channelInfo.broadcasterId = QString::fromStdString(channelObj.value("broadcaster_id", "")); + channelInfo.broadcasterLogin = + QString::fromStdString(channelObj.value("broadcaster_login", "")); + channelInfo.broadcasterName = QString::fromStdString(channelObj.value("broadcaster_name", "")); + channelInfo.gameName = QString::fromStdString(channelObj.value("game_name", "")); + channelInfo.gameId = QString::fromStdString(channelObj.value("game_id", "")); + channelInfo.broadcasterLanguage = + QString::fromStdString(channelObj.value("broadcaster_language", "")); + channelInfo.title = QString::fromStdString(channelObj.value("title", "")); + channelInfo.delay = channelObj.value("delay", 0); + + return channelInfo; +} + +void OneSevenLiveTwitchClient::clearCache() { + m_cachedUserInfo = TwitchUserInfo(); + m_cachedChannelInfo = TwitchChannelInfo(); +} diff --git a/src/17live/twitch/OneSevenLiveTwitchClient.hpp b/src/17live/twitch/OneSevenLiveTwitchClient.hpp new file mode 100644 index 0000000..a07a079 --- /dev/null +++ b/src/17live/twitch/OneSevenLiveTwitchClient.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +struct TwitchUserInfo { + QString id; + QString login; + QString displayName; + QString type; + QString broadcasterType; + QString description; + QString profileImageUrl; + QString offlineImageUrl; + qint64 viewCount; + QString email; + QString createdAt; + + TwitchUserInfo() : viewCount(0) {} +}; + +struct TwitchChannelInfo { + QString broadcasterId; + QString broadcasterLogin; + QString broadcasterName; + QString gameName; + QString gameId; + QString broadcasterLanguage; + QString title; + qint64 delay; + + TwitchChannelInfo() : delay(0) {} +}; + +class OneSevenLiveTwitchClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveTwitchClient(QObject* parent = nullptr); + ~OneSevenLiveTwitchClient(); + + // Authentication + void setAuthData(const QString& accessToken, const QString& clientId); + bool hasValidAuth() const; + + // User information API + void getCurrentUser(); + void getUserById(const QString& userId); + void getUserByLogin(const QString& login); + + // Channel information API + void getChannelInformation(const QString& broadcasterId); + + // Stream key API + void getStreamKey(const QString& broadcasterId); + + // Get cached user info + TwitchUserInfo getCachedUserInfo() const { + return m_cachedUserInfo; + } + + TwitchChannelInfo getCachedChannelInfo() const { + return m_cachedChannelInfo; + } + + signals: + void userInfoReceived(const TwitchUserInfo& userInfo); + void channelInfoReceived(const TwitchChannelInfo& channelInfo); + void errorOccurred(const QString& errorMessage); + void streamKeyReceived(const QString& streamKey); + + private slots: + void onUserInfoResult(const QString& text, const QString& error); + void onChannelInfoResult(const QString& text, const QString& error); + void onStreamKeyResult(const QString& text, const QString& error); + + private: + void makeApiRequest(const QString& endpoint, const QString& query = QString()); + TwitchUserInfo parseUserInfo(const nlohmann::json& userObj); + TwitchChannelInfo parseChannelInfo(const nlohmann::json& channelObj); + void clearCache(); + + // Authentication data + QString m_accessToken; + QString m_clientId; + + // Cached data + TwitchUserInfo m_cachedUserInfo; + TwitchChannelInfo m_cachedChannelInfo; + + // API endpoints + static const QString TWITCH_HELIX_API_BASE; + static const QString TWITCH_USERS_ENDPOINT; + static const QString TWITCH_CHANNELS_ENDPOINT; + static const QString TWITCH_STREAM_KEY_ENDPOINT; + + public: + static const QString TWITCH_RTMP_SERVER; +}; diff --git a/src/17live/ui/OneSevenLiveAuthDialog.cpp b/src/17live/ui/OneSevenLiveAuthDialog.cpp new file mode 100644 index 0000000..84c2ba7 --- /dev/null +++ b/src/17live/ui/OneSevenLiveAuthDialog.cpp @@ -0,0 +1,83 @@ +// OneSevenLiveAuthDialog: embed QCefView to show external URL and re-emit URL changes +#include "OneSevenLiveAuthDialog.hpp" + +#include + +#include +#include +// #include + +#include "../../plugin-support.h" +#include "../chat/cef_panel.hpp" +#include "moc_OneSevenLiveAuthDialog.cpp" + +OneSevenLiveAuthDialog::OneSevenLiveAuthDialog(QWidget* parent) + : QDialog(parent), cef_(nullptr), cefWidget_(nullptr) { + setupUi(); +} + +OneSevenLiveAuthDialog::OneSevenLiveAuthDialog(const QString& url, QWidget* parent) + : QDialog(parent), cef_(nullptr), cefWidget_(nullptr) { + setupUi(); + setUrl(url); +} + +OneSevenLiveAuthDialog::~OneSevenLiveAuthDialog() { + obs_log(LOG_INFO, "OneSevenLiveAuthDialog: destructor"); +} + +void OneSevenLiveAuthDialog::setupUi() { + setWindowTitle("Authorization"); + setModal(true); + resize(800, 600); + + // QDialog is typically already a native window. + // Explicitly setting WA_NativeWindow might be redundant or cause issues with child native + // widgets. setAttribute(Qt::WA_NativeWindow); + + cef_ = obs_browser_init_panel(); + if (cef_) { + cef_->init_browser(); + // Initialize with about:blank; real URL set via setUrl() + cefWidget_ = cef_->create_widget(this, "about:blank"); + if (cefWidget_) { + cefWidget_->show(); + + // Connect urlChanged signal dynamically since QCefWidget interface doesn't expose it + // but the underlying implementation (obs-browser panel) does. + connect(cefWidget_, SIGNAL(urlChanged(const QString&)), this, + SIGNAL(urlChanged(const QString&))); + } + } else { + obs_log(LOG_ERROR, "OneSevenLiveAuthDialog: Failed to initialize obs-browser panel"); + } +} + +void OneSevenLiveAuthDialog::resizeEvent(QResizeEvent* event) { + QDialog::resizeEvent(event); + if (cefWidget_) { + cefWidget_->setGeometry(rect()); + } +} + +void OneSevenLiveAuthDialog::setUrl(const QString& url) { + if (cefWidget_) { + cefWidget_->setURL(url.toStdString()); + } +} + +void OneSevenLiveAuthDialog::accept() { + if (cefWidget_) { + delete cefWidget_; + cefWidget_ = nullptr; + } + QDialog::accept(); +} + +void OneSevenLiveAuthDialog::reject() { + if (cefWidget_) { + delete cefWidget_; + cefWidget_ = nullptr; + } + QDialog::reject(); +} diff --git a/src/17live/ui/OneSevenLiveAuthDialog.hpp b/src/17live/ui/OneSevenLiveAuthDialog.hpp new file mode 100644 index 0000000..54ef645 --- /dev/null +++ b/src/17live/ui/OneSevenLiveAuthDialog.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +class QCefView; +struct QCef; +class QCefWidget; + +/** + * Authorization dialog using embedded CEF view. + * Loads a given URL and forwards URL changes to external listeners. + */ +class OneSevenLiveAuthDialog : public QDialog { + Q_OBJECT + + public: + explicit OneSevenLiveAuthDialog(QWidget* parent = nullptr); + explicit OneSevenLiveAuthDialog(const QString& url, QWidget* parent = nullptr); + ~OneSevenLiveAuthDialog(); + + // Set or update the URL in the embedded browser + void setUrl(const QString& url); + + public slots: + void accept() override; + void reject() override; + + signals: + // Emitted when the embedded browser URL changes + void urlChanged(const QString& url); + + protected: + void resizeEvent(QResizeEvent* event) override; + + private: + void setupUi(); + QCef* cef_ = nullptr; + QCefWidget* cefWidget_ = nullptr; +}; diff --git a/src/17live/ui/OneSevenLiveLineEditWithEye.cpp b/src/17live/ui/OneSevenLiveLineEditWithEye.cpp new file mode 100644 index 0000000..187fcc1 --- /dev/null +++ b/src/17live/ui/OneSevenLiveLineEditWithEye.cpp @@ -0,0 +1,82 @@ +#include "OneSevenLiveLineEditWithEye.hpp" + +#include +#include + +#include "moc_OneSevenLiveLineEditWithEye.cpp" + +OneSevenLiveLineEditWithEye::OneSevenLiveLineEditWithEye(QWidget* parent) : QWidget(parent) { + QHBoxLayout* rootLayout = new QHBoxLayout(this); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + // Password input field + m_lineEdit = new QLineEdit(this); + m_lineEdit->setEchoMode(QLineEdit::Password); + m_lineEdit->setFixedHeight(40); + m_lineEdit->setStyleSheet( + "QLineEdit {" + " border: none;" + " border-radius: 2px 0 0 2px;" + " padding: 0 15px;" + "}"); + + // Show/hide password button + m_eyeButton = new QPushButton(this); + + // Set initial icon to show password icon + QIcon showIcon(":/resources/show-password.svg"); + m_eyeButton->setIcon(showIcon); + m_eyeButton->setIconSize(QSize(20, 20)); + m_eyeButton->setFixedSize(40, 40); + m_eyeButton->setStyleSheet( + "QPushButton {" + " border: none;" + " border-radius: 0 2px 2px 0;" + " margin: 0;" + " padding: 0;" + "}"); + + rootLayout->addWidget(m_lineEdit); + rootLayout->addWidget(m_eyeButton); + rootLayout->setAlignment(m_lineEdit, Qt::AlignVCenter); + rootLayout->setAlignment(m_eyeButton, Qt::AlignVCenter); + + // Connect button click event + QPointer safeButton(m_eyeButton); + QPointer safeLineEdit(m_lineEdit); + QPointer safeThis(this); + connect(safeButton, &QPushButton::clicked, safeThis, [safeLineEdit, safeButton, safeThis]() { + if (safeLineEdit->echoMode() == QLineEdit::Password) { + safeLineEdit->setEchoMode(QLineEdit::Normal); + // Switch to hide password icon + QIcon hideIcon(":/resources/hide-password.svg"); + safeButton->setIcon(hideIcon); + } else { + safeLineEdit->setEchoMode(QLineEdit::Password); + // Switch to show password icon + QIcon showIcon(":/resources/show-password.svg"); + safeButton->setIcon(showIcon); + } + }); +} + +OneSevenLiveLineEditWithEye::~OneSevenLiveLineEditWithEye() { + if (m_eyeButton) { + disconnect(m_eyeButton, nullptr, this, nullptr); + } + if (m_lineEdit) { + disconnect(m_lineEdit, nullptr, this, nullptr); + } + m_eyeButton = nullptr; + m_lineEdit = nullptr; +} + +QString OneSevenLiveLineEditWithEye::text() const { + return m_lineEdit->text(); +} + +void OneSevenLiveLineEditWithEye::setText(const QString& text) { + m_lineEdit->setText(text); +} diff --git a/src/17live/ui/OneSevenLiveLineEditWithEye.hpp b/src/17live/ui/OneSevenLiveLineEditWithEye.hpp new file mode 100644 index 0000000..0e4a60d --- /dev/null +++ b/src/17live/ui/OneSevenLiveLineEditWithEye.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +class OneSevenLiveLineEditWithEye : public QWidget { + Q_OBJECT + + public: + OneSevenLiveLineEditWithEye(QWidget *parent = nullptr); + ~OneSevenLiveLineEditWithEye(); + + void setText(const QString &text); + QString text() const; + + private: + QLineEdit *m_lineEdit = nullptr; + QPushButton *m_eyeButton = nullptr; +}; diff --git a/src/17live/ui/OneSevenLivePropertiesWidget.cpp b/src/17live/ui/OneSevenLivePropertiesWidget.cpp new file mode 100644 index 0000000..7ee0f08 --- /dev/null +++ b/src/17live/ui/OneSevenLivePropertiesWidget.cpp @@ -0,0 +1,373 @@ +#include "OneSevenLivePropertiesWidget.hpp" + +#include + +#include +#include +#include +#include + +#include "OneSevenLivePropertyWidget.hpp" +#include "plugin-support.h" +#include "utility/Common.hpp" + +OneSevenLivePropertiesWidget::OneSevenLivePropertiesWidget(QWidget *parent, obs_data_t *settings, + obs_properties_t *props) + : QWidget(parent), m_origSettings(settings), m_settings(nullptr), m_props(props) { + // Initialize internal settings store + m_settings = obs_data_create(); + + // If original settings provided, seed with defaults then apply originals + if (m_origSettings) { + ObsDataPtr defaultSettings{obs_data_get_defaults(m_origSettings)}; + if (defaultSettings) { + obs_data_apply(m_settings, defaultSettings.get()); + defaultSettings.reset(); + } + obs_data_apply(m_settings, m_origSettings); + } + + // Minimal UI: start with an empty form layout so the widget renders blank + m_formLayout = new QFormLayout(); + m_formLayout->setRowWrapPolicy(QFormLayout::WrapAllRows); + m_formLayout->setLabelAlignment(Qt::AlignLeft); + m_formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + m_formLayout->setContentsMargins(0, 0, 0, 0); + setLayout(m_formLayout); + + // If properties are provided, apply and build the property controls now + if (m_props) { + obs_properties_apply_settings(m_props, m_settings); + RefreshUI(); + } +} + +OneSevenLivePropertiesWidget::~OneSevenLivePropertiesWidget() { + if (m_props) + obs_properties_destroy(m_props); + m_props = nullptr; + + if (m_settings) + obs_data_release(m_settings); + m_settings = nullptr; + + if (m_origSettings) + obs_data_release(m_origSettings); + m_origSettings = nullptr; +} + +void OneSevenLivePropertiesWidget::RefreshUI() { + if (isRefreshing) + return; + isRefreshing = true; + + for (auto &x : m_propertyWidgets) { + x.second->SaveData(m_settings); + } + + { + obs_log(LOG_INFO, "[RefreshUI] Dumping settings"); + // iterate all m_settings and display property name, type and description + obs_data_item_t *item = obs_data_first(m_settings); + while (item) { + const char *name_cstr = obs_data_item_get_name(item); + if (name_cstr) { + std::string name(name_cstr); + obs_log(LOG_INFO, "[RefreshUI] Setting %s, type %d", name.c_str(), + static_cast(obs_data_item_gettype(item))); + } + obs_data_item_next(&item); + } + } + + { + obs_log(LOG_INFO, "[RefreshUI] Dumping properties"); + // iterate all m_props and display property name, type and description + obs_property_t *prop = obs_properties_first(m_props); + while (prop) { + const char *name_cstr = obs_property_name(prop); + if (name_cstr) { + std::string name(name_cstr); + obs_log(LOG_INFO, "[RefreshUI] Property %s, description %s", name.c_str(), + obs_property_description(prop)); + } + obs_property_next(&prop); + } + } + + obs_properties_apply_settings(m_props, m_settings); + loadProperties(); + + for (auto &x : m_propertyWidgets) { + x.second->LoadData(m_settings); + } + + isRefreshing = false; +} + +void OneSevenLivePropertiesWidget::loadProperties() { + obs_log(LOG_DEBUG, "[loadProperties] Starting property loading"); + + std::unordered_map> origPropWidgets; + origPropWidgets.swap(m_propertyWidgets); + + for (auto &x : origPropWidgets) { + if (x.second) + x.second->hide(); + if (x.second->label) + m_formLayout->removeWidget(x.second->label); + if (x.second->ctrl) + m_formLayout->removeWidget(x.second->ctrl); + if (x.second->container) + m_formLayout->removeWidget(x.second->container); + } + + if (!m_props) { + obs_log(LOG_WARNING, "[loadProperties] m_props is null, skipping property loading"); + return; + } + + obs_property_t *prop = obs_properties_first(m_props); + if (!prop) { + obs_log(LOG_INFO, "[loadProperties] No properties found"); + return; + } + + int propertyCount = 0; + while (prop) { + propertyCount++; + obs_log(LOG_DEBUG, "[loadProperties] Processing property %d", propertyCount); + + if (!obs_property_visible(prop)) { + obs_log(LOG_DEBUG, "[loadProperties] Property %d is not visible, skipping", + propertyCount); + if (!obs_property_next(&prop)) + break; + continue; + } + + const char *name_cstr = obs_property_name(prop); + if (!name_cstr) { + obs_log(LOG_ERROR, "[loadProperties] Property %d has null name, skipping", + propertyCount); + if (!obs_property_next(&prop)) + break; + continue; + } + + std::string name(name_cstr); + if (name == "service" || name == "show_all") { + if (!obs_property_next(&prop)) + break; + continue; + } + obs_log(LOG_DEBUG, "[loadProperties] Processing property: %s", name.c_str()); + + auto it = origPropWidgets.find(name); + if (it == origPropWidgets.end()) { + obs_log(LOG_DEBUG, "[loadProperties] Creating new widget for property: %s", + name.c_str()); + try { + auto newWidget = std::make_shared(this, this, prop); + if (!newWidget || !newWidget->label || !newWidget->ctrl) { + obs_log(LOG_ERROR, "[loadProperties] Failed to create widget for property: %s", + name.c_str()); + if (!obs_property_next(&prop)) + break; + continue; + } + newWidget->LoadData(m_settings); + m_propertyWidgets.insert(std::make_pair(newWidget->name, newWidget)); + newWidget->show(); + if (newWidget->container) + m_formLayout->addWidget(newWidget->container); + else + m_formLayout->addRow(newWidget->label, newWidget->ctrl); + obs_log(LOG_DEBUG, "[loadProperties] Successfully created widget for property: %s", + name.c_str()); + } catch (const std::exception &e) { + obs_log(LOG_ERROR, "[loadProperties] Exception creating widget for property %s: %s", + name.c_str(), e.what()); + if (!obs_property_next(&prop)) + break; + continue; + } + } else { + obs_log(LOG_DEBUG, "[loadProperties] Reusing existing widget for property: %s", + name.c_str()); + try { + it->second->ReloadProperty(prop); + it->second->LoadData(m_settings); + m_propertyWidgets.insert(std::make_pair(it->first, it->second)); + it->second->show(); + if (it->second->container) + m_formLayout->addWidget(it->second->container); + else + m_formLayout->addRow(it->second->label, it->second->ctrl); + obs_log(LOG_DEBUG, "[loadProperties] Successfully reused widget for property: %s", + name.c_str()); + } catch (const std::exception &e) { + obs_log(LOG_ERROR, "[loadProperties] Exception reusing widget for property %s: %s", + name.c_str(), e.what()); + if (!obs_property_next(&prop)) + break; + continue; + } + } + + if (!obs_property_next(&prop)) { + obs_log(LOG_DEBUG, "[loadProperties] Reached end of properties"); + break; + } + + if (propertyCount > 100) { + obs_log(LOG_ERROR, + "[loadProperties] Too many properties (%d), breaking to prevent infinite loop", + propertyCount); + break; + } + } + + obs_log(LOG_INFO, "[loadProperties] Processed %d properties successfully", propertyCount); +} + +void OneSevenLivePropertiesWidget::UpdateProperties(obs_data_t *settings, obs_properties_t *props) { + obs_log(LOG_INFO, "[UpdateProperties] Starting with settings: %p, props: %p", (void *) settings, + (void *) props); + + // Validate input parameters + if (!settings) { + obs_log(LOG_ERROR, "[UpdateProperties] settings parameter is null"); + return; + } + + if (!props) { + obs_log(LOG_ERROR, "[UpdateProperties] props parameter is null"); + if (settings) { + obs_data_release(settings); + } + return; + } + + obs_log(LOG_DEBUG, "[UpdateProperties] Removing existing controls and layout"); + + obs_log(LOG_DEBUG, "[UpdateProperties] Cleaning up %zu existing property widgets", + m_propertyWidgets.size()); + for (auto &kv : m_propertyWidgets) { + if (kv.second) { + if (kv.second->label) { + m_formLayout->removeWidget(kv.second->label); + kv.second->label->deleteLater(); + } + if (kv.second->ctrl) { + m_formLayout->removeWidget(kv.second->ctrl); + kv.second->ctrl->deleteLater(); + } + if (kv.second->container) { + m_formLayout->removeWidget(kv.second->container); + kv.second->container->deleteLater(); + } + } + } + + m_propertyWidgets.clear(); + + obs_log(LOG_DEBUG, "[UpdateProperties] Releasing previous OBS objects"); + + // Release previous OBS objects + if (m_props) { + obs_log(LOG_DEBUG, "[UpdateProperties] Destroying previous props: %p", (void *) m_props); + obs_properties_destroy(m_props); + m_props = nullptr; + } + if (m_settings) { + obs_log(LOG_DEBUG, "[UpdateProperties] Releasing previous settings: %p", + (void *) m_settings); + obs_data_release(m_settings); + m_settings = nullptr; + } + if (m_origSettings) { + obs_log(LOG_DEBUG, "[UpdateProperties] Releasing previous origSettings: %p", + (void *) m_origSettings); + obs_data_release(m_origSettings); + m_origSettings = nullptr; + } + + obs_log(LOG_DEBUG, "[UpdateProperties] Taking ownership of new OBS structures"); + + // Take ownership of new OBS structures + m_origSettings = settings; + m_props = props; + + obs_log(LOG_DEBUG, "[UpdateProperties] Initializing settings storage"); + + // Initialize settings storage from defaults and provided originals + m_settings = obs_data_create(); + if (!m_settings) { + obs_log(LOG_ERROR, "[UpdateProperties] Failed to create settings data"); + return; + } + + if (m_origSettings) { + obs_log(LOG_DEBUG, "[UpdateProperties] Applying default and original settings"); + ObsDataPtr defaultSettings{obs_data_get_defaults(m_origSettings)}; + if (defaultSettings) { + obs_data_apply(m_settings, defaultSettings.get()); + defaultSettings.reset(); + obs_log(LOG_DEBUG, "[UpdateProperties] Applied default settings"); + } + obs_data_apply(m_settings, m_origSettings); + obs_log(LOG_DEBUG, "[UpdateProperties] Applied original settings"); + } + + obs_log(LOG_DEBUG, "[UpdateProperties] Building property controls"); + + // Build property controls if properties provided + if (m_props) { + try { + obs_log(LOG_DEBUG, "[UpdateProperties] Applying settings to properties"); + obs_properties_apply_settings(m_props, m_settings); + + obs_log(LOG_DEBUG, "[UpdateProperties] Loading properties"); + loadProperties(); + + obs_log(LOG_DEBUG, "[UpdateProperties] Loading data for %zu property widgets", + m_propertyWidgets.size()); + for (auto &kv : m_propertyWidgets) { + if (kv.second) { + kv.second->LoadData(m_settings); + } + } + obs_log(LOG_DEBUG, "[UpdateProperties] All property widgets loaded successfully"); + } catch (const std::exception &e) { + obs_log(LOG_ERROR, "[UpdateProperties] Exception during property loading: %s", + e.what()); + } catch (...) { + obs_log(LOG_ERROR, "[UpdateProperties] Unknown exception during property loading"); + } + } else { + obs_log(LOG_WARNING, + "[UpdateProperties] No properties provided, skipping property loading"); + } + + obs_log(LOG_INFO, "[UpdateProperties] Completed successfully"); +} + +nlohmann::json OneSevenLivePropertiesWidget::SaveData() { + for (auto &kv : m_propertyWidgets) { + if (kv.second) { + kv.second->SaveData(m_settings); + } + } + + auto jsonstr = obs_data_get_json(m_settings); + obs_log(LOG_DEBUG, "[SaveData] Saving data to JSON: %s", jsonstr); + if (!jsonstr) + return {}; + try { + return nlohmann::json::parse(jsonstr); + } catch (const nlohmann::json::parse_error &e) { + obs_log(LOG_ERROR, "[SaveData] JSON parse error: %s", e.what()); + return {}; + } +} diff --git a/src/17live/ui/OneSevenLivePropertiesWidget.hpp b/src/17live/ui/OneSevenLivePropertiesWidget.hpp new file mode 100644 index 0000000..af63ca5 --- /dev/null +++ b/src/17live/ui/OneSevenLivePropertiesWidget.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include +#include + +#include "OneSevenLivePropertyRefreshHandler.hpp" +#include "nlohmann/json.hpp" + +class OneSevenLivePropertyWidget; + +class OneSevenLivePropertiesWidget : public QWidget, public OneSevenLivePropertyRefreshHandler { + Q_OBJECT + + private: + obs_data_t *m_origSettings = nullptr; + obs_data_t *m_settings = nullptr; + obs_properties_t *m_props = nullptr; + + public: + OneSevenLivePropertiesWidget(QWidget *parent = nullptr, obs_data_t *settings = nullptr, + obs_properties_t *props = nullptr); + ~OneSevenLivePropertiesWidget(); + + void UpdateProperties(obs_data_t *settings, obs_properties_t *props); + void RefreshUI() override; + nlohmann::json SaveData(); + + private: + QFormLayout *m_formLayout = nullptr; + + std::unordered_map> m_propertyWidgets; + + bool isRefreshing = false; + + void loadProperties(); +}; diff --git a/src/17live/ui/OneSevenLivePropertyRefreshHandler.hpp b/src/17live/ui/OneSevenLivePropertyRefreshHandler.hpp new file mode 100644 index 0000000..3e33813 --- /dev/null +++ b/src/17live/ui/OneSevenLivePropertyRefreshHandler.hpp @@ -0,0 +1,6 @@ +#pragma once + +struct OneSevenLivePropertyRefreshHandler { + virtual ~OneSevenLivePropertyRefreshHandler() = default; + virtual void RefreshUI() = 0; +}; diff --git a/src/17live/ui/OneSevenLivePropertyWidget.cpp b/src/17live/ui/OneSevenLivePropertyWidget.cpp new file mode 100644 index 0000000..e1825f2 --- /dev/null +++ b/src/17live/ui/OneSevenLivePropertyWidget.cpp @@ -0,0 +1,323 @@ +#include "OneSevenLivePropertyWidget.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveLineEditWithEye.hpp" +#include "OneSevenLivePropertyRefreshHandler.hpp" +#include "plugin-support.h" + +OneSevenLivePropertyWidget::OneSevenLivePropertyWidget( + QWidget *parent, OneSevenLivePropertyRefreshHandler *refreshHandler, obs_property *property) + : QWidget(parent), m_refreshHandler(refreshHandler), m_property(property) { + name = obs_property_name(m_property); + + const char *desc = obs_property_description(m_property); + if (!desc) + desc = obs_property_name(m_property); + + obs_log(LOG_DEBUG, "[OneSevenLivePropertyWidget] Constructor called for property: %s [%s]", + name.c_str(), desc); + + label = new QLabel(desc, this); + label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + + m_propertyType = obs_property_get_type(property); + + switch (m_propertyType) { + case OBS_PROPERTY_BOOL: { + auto cb = new QCheckBox(this); + // Defer signal connection to avoid triggering recursive RefreshUI calls during construction + QPointer safeCb(cb); + QPointer safeThis(this); + QTimer::singleShot(0, [this, safeCb, safeThis]() { + if (!safeCb || !safeThis) + return; + + if (safeThis && safeThis->m_refreshHandler) { + QObject::connect(safeCb, &QCheckBox::stateChanged, [safeThis]() { + if (safeThis && safeThis->m_refreshHandler) + safeThis->m_refreshHandler->RefreshUI(); + }); + } + }); + ctrl = cb; + + container = new QWidget(this); + QHBoxLayout *hl = new QHBoxLayout(container); + hl->addWidget(label); + hl->addStretch(); + hl->addWidget(ctrl); + + QVBoxLayout *vl = new QVBoxLayout(this); + vl->addWidget(container); + + break; + } + case OBS_PROPERTY_INT: { + auto le = new QLineEdit(this); + le->setValidator(new QIntValidator(le)); + le->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + ctrl = le; + break; + } + case OBS_PROPERTY_FLOAT: { + auto le = new QLineEdit(this); + le->setValidator(new QDoubleValidator(le)); + le->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + ctrl = le; + break; + } + case OBS_PROPERTY_TEXT: { + if (obs_property_text_type(property) == OBS_TEXT_PASSWORD) { + auto le = new OneSevenLiveLineEditWithEye(this); + ctrl = static_cast(le); + m_isPassword = true; + } else { + auto le = new QLineEdit(this); + ctrl = le; + } + break; + } + case OBS_PROPERTY_LIST: { + auto cb = new QComboBox(this); + cb->setEditable(false); + cb->setInsertPolicy(QComboBox::NoInsert); + + QPointer safeCb(cb); + QPointer safeThis(this); + QTimer::singleShot(0, [safeCb, safeThis]() { + if (!safeCb || !safeThis) + return; + + if (safeThis && safeThis->m_refreshHandler) { + QObject::connect(safeCb, &QComboBox::currentIndexChanged, [safeThis]() { + if (safeThis && safeThis->m_refreshHandler) + safeThis->m_refreshHandler->RefreshUI(); + }); + } + }); + ctrl = cb; + break; + } + default: + ctrl = new QLabel("Unsupported", this); + break; + } + + QObject::connect(label, &QObject::destroyed, [this]() { label = nullptr; }); + QObject::connect(ctrl, &QObject::destroyed, [this]() { ctrl = nullptr; }); + + ReloadProperty(property); +} + +OneSevenLivePropertyWidget::~OneSevenLivePropertyWidget() { + obs_log(LOG_DEBUG, "[~OneSevenLivePropertyWidget] Destructor called for property: %s", + name.c_str()); + + // Disconnect all signals to prevent crashes during destruction + disconnect(this); + + if (ctrl) { + disconnect(ctrl, nullptr, nullptr, nullptr); + } + + if (label) { + disconnect(label, nullptr, nullptr, nullptr); + } + + ctrl = nullptr; + label = nullptr; + container = nullptr; +} + +void OneSevenLivePropertyWidget::ReloadProperty(obs_property *property) { + if (!property) + return; + m_property = property; + if (obs_property_get_type(property) == m_propertyType) { + switch (m_propertyType) { + case OBS_PROPERTY_LIST: { + auto cb = qobject_cast(ctrl); + if (!cb) + break; + for (int i = cb->count() - 1; i >= 0; --i) + cb->removeItem(i); + m_comboFormat = obs_property_list_format(property); + const size_t cnt = obs_property_list_item_count(property); + for (size_t i = 0; i < cnt; ++i) { + const char *itemname = obs_property_list_item_name(property, i); + QVariant data; + if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_INT) + data = obs_property_list_item_int(property, i); + else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_FLOAT) + data = obs_property_list_item_float(property, i); + else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_STRING) + data = QString(obs_property_list_item_string(property, i)); + cb->addItem(itemname ? itemname : "", data); + } + break; + } + default: + // obs_log(LOG_WARNING, "ReloadProperty did not handle property of type %d", + // (int)m_propertyType); + break; + } + } +} + +void OneSevenLivePropertyWidget::LoadData(obs_data_t *settings) { + if (!settings || !ctrl) + return; + switch (m_propertyType) { + case OBS_PROPERTY_BOOL: { + auto cb = qobject_cast(ctrl); + if (cb) { + bool v = obs_data_get_bool(settings, name.c_str()); + cb->setChecked(v); + } + break; + } + case OBS_PROPERTY_INT: { + auto le = qobject_cast(ctrl); + if (le) { + int v = (int) obs_data_get_int(settings, name.c_str()); + le->setText(QString::number(v)); + } + break; + } + case OBS_PROPERTY_FLOAT: { + auto le = qobject_cast(ctrl); + if (le) { + double v = obs_data_get_double(settings, name.c_str()); + le->setText(QString::number(v)); + } + break; + } + case OBS_PROPERTY_TEXT: { + if (m_isPassword) { + auto le = qobject_cast(ctrl); + if (le) { + const char *str = obs_data_get_string(settings, name.c_str()); + le->setText(QString(str ? str : "")); + } + } else { + auto le = qobject_cast(ctrl); + if (le) { + const char *str = obs_data_get_string(settings, name.c_str()); + le->setText(QString(str ? str : "")); + } + } + break; + } + case OBS_PROPERTY_LIST: { + auto cb = qobject_cast(ctrl); + if (!cb) + break; + int indexToSelect = -1; + if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_INT) { + int target = (int) obs_data_get_int(settings, name.c_str()); + for (int i = 0; i < cb->count(); ++i) { + if (cb->itemData(i).toInt() == target) { + indexToSelect = i; + break; + } + } + } else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_FLOAT) { + double target = obs_data_get_double(settings, name.c_str()); + for (int i = 0; i < cb->count(); ++i) { + if (cb->itemData(i).toDouble() == target) { + indexToSelect = i; + break; + } + } + } else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_STRING) { + const char *target = obs_data_get_string(settings, name.c_str()); + for (int i = 0; i < cb->count(); ++i) { + if (cb->itemData(i).toString() == QString(target ? target : "")) { + indexToSelect = i; + break; + } + } + } + if (indexToSelect >= 0) + cb->setCurrentIndex(indexToSelect); + break; + } + default: + break; + } +} + +void OneSevenLivePropertyWidget::SaveData(obs_data_t *settings) { + obs_log(LOG_DEBUG, "Saving property %s", name.c_str()); + if (!settings || !ctrl) + return; + obs_log(LOG_DEBUG, "Saving property %s as %d", name.c_str(), (int) m_propertyType); + switch (m_propertyType) { + case OBS_PROPERTY_BOOL: { + auto cb = qobject_cast(ctrl); + if (cb) + obs_data_set_bool(settings, name.c_str(), cb->isChecked()); + break; + } + case OBS_PROPERTY_INT: { + auto le = qobject_cast(ctrl); + if (le) { + bool ok = false; + int v = le->text().toInt(&ok); + if (ok) + obs_data_set_int(settings, name.c_str(), v); + } + break; + } + case OBS_PROPERTY_FLOAT: { + auto le = qobject_cast(ctrl); + if (le) { + bool ok = false; + double v = le->text().toDouble(&ok); + if (ok) + obs_data_set_double(settings, name.c_str(), v); + } + break; + } + case OBS_PROPERTY_TEXT: { + obs_log(LOG_DEBUG, "Saving property %s as string", name.c_str()); + if (m_isPassword) { + auto le = qobject_cast(ctrl); + if (le) + obs_data_set_string(settings, name.c_str(), le->text().toUtf8().constData()); + } else { + auto le = qobject_cast(ctrl); + if (le) + obs_data_set_string(settings, name.c_str(), le->text().toUtf8().constData()); + } + break; + } + case OBS_PROPERTY_LIST: { + auto cb = qobject_cast(ctrl); + if (!cb) + break; + QVariant data = cb->currentData(); + if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_INT) { + obs_data_set_int(settings, name.c_str(), data.toInt()); + } else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_FLOAT) { + obs_data_set_double(settings, name.c_str(), data.toDouble()); + } else if (m_comboFormat == obs_combo_format::OBS_COMBO_FORMAT_STRING) { + QString s = data.toString(); + obs_data_set_string(settings, name.c_str(), s.toUtf8().constData()); + } + break; + } + default: + break; + } +} diff --git a/src/17live/ui/OneSevenLivePropertyWidget.hpp b/src/17live/ui/OneSevenLivePropertyWidget.hpp new file mode 100644 index 0000000..268d42e --- /dev/null +++ b/src/17live/ui/OneSevenLivePropertyWidget.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include +#include +#include + +struct OneSevenLivePropertyRefreshHandler; + +struct obs_property; +struct obs_data; + +class QLabel; + +class OneSevenLivePropertyWidget : public QWidget { + Q_OBJECT + + public: + OneSevenLivePropertyWidget(QWidget *parent = nullptr, + OneSevenLivePropertyRefreshHandler *refreshHandler = nullptr, + obs_property *property = nullptr); + ~OneSevenLivePropertyWidget(); + + void ReloadProperty(obs_property *property); + void LoadData(obs_data *settings); + void SaveData(obs_data *settings); + + QLabel *label = nullptr; + QWidget *ctrl = nullptr; + QWidget *container = nullptr; + std::string name; + + private: + OneSevenLivePropertyRefreshHandler *m_refreshHandler = nullptr; + obs_property *m_property = nullptr; + obs_property_type m_propertyType = {}; + bool m_isPassword = false; + obs_combo_format m_comboFormat = {}; +}; diff --git a/src/17live/utility/Common.cpp b/src/17live/utility/Common.cpp index b1d044b..6f1a80c 100644 --- a/src/17live/utility/Common.cpp +++ b/src/17live/utility/Common.cpp @@ -1,5 +1,6 @@ #include "Common.hpp" +#include #include #include @@ -20,8 +21,58 @@ #include // For std::stringstream (Linux) #endif +#include +#include + #include "plugin-support.h" +// Helper class for QThreadPool +class TaskRunnable : public QRunnable { + public: + std::function m_task; + + TaskRunnable(std::function task) : m_task(task) { + setAutoDelete(true); + } + + void run() override { + if (m_task) + m_task(); + } +}; + +static QThreadPool* s_threadPool = nullptr; + +void InitThreadPool() { + if (!s_threadPool) { + s_threadPool = new QThreadPool(); + // Set max thread count if needed, or leave default + obs_log(LOG_INFO, "ThreadPool initialized"); + } +} + +void DestroyThreadPool() { + if (s_threadPool) { + obs_log(LOG_INFO, "Destroying ThreadPool - waiting for tasks..."); + s_threadPool->clear(); // Clear pending tasks + s_threadPool->waitForDone(); // Wait for running tasks + delete s_threadPool; + s_threadPool = nullptr; + obs_log(LOG_INFO, "ThreadPool destroyed"); + } +} + +void ScheduleOBSTask(std::function task) { + if (s_threadPool) { + s_threadPool->start(new TaskRunnable(task)); + } else { + // Fallback to global if not initialized (e.g. early init or unit tests), + // but warn about it + // obs_log(LOG_WARNING, "ScheduleOBSTask called without local ThreadPool, using global"); + QThreadPool::globalInstance()->start(new TaskRunnable(task)); + } +} + std::string GetCurrentLanguage() { const char* locale = obs_get_locale(); if (strcmp(locale, "ja-JP") == 0) { @@ -223,3 +274,32 @@ std::string GetCurrentPlatformUUID() { return "Unsupported OS for UUID"; #endif } + +obs_data_t* ObsDataFromJson(nlohmann::json j) { + obs_data_t* r = nullptr; + + if (j.type() == nlohmann::json::value_t::null) { + r = obs_data_create(); + obs_log(LOG_DEBUG, "[ObsDataFromJson] Created empty obs_data_t for null JSON"); + } else { + auto jstr = j.dump(); + r = obs_data_create_from_json(jstr.c_str()); + if (!r) { + obs_log(LOG_ERROR, "[ObsDataFromJson] Failed to create obs_data_t from JSON: %s", + jstr.c_str()); + return nullptr; + } + obs_log(LOG_DEBUG, "[ObsDataFromJson] Created obs_data_t from JSON: %s", jstr.c_str()); + } + + // DO NOT release here - caller is responsible for managing the returned pointer + return r; +} + +std::string get_obs_module_data_path_str() { + const char* path = obs_get_module_data_path(obs_current_module()); + if (!path) { + return ""; + } + return std::string(path); +} diff --git a/src/17live/utility/Common.hpp b/src/17live/utility/Common.hpp index 889ba4a..0ae3b2b 100644 --- a/src/17live/utility/Common.hpp +++ b/src/17live/utility/Common.hpp @@ -1,5 +1,9 @@ #pragma once +#include + +#include +#include #include #define OS_WINDOWS "Windows" @@ -7,8 +11,32 @@ #define OS_LINUX "Linux" #define OS_UNKNOWN "Unknown" +#include + +// ... existing includes ... + std::string GetCurrentOS(); std::string GetCurrentOSVersion(); std::string GetCurrentPlatformUUID(); std::string GetCurrentLanguage(); std::string GetCurrentLocale(); + +obs_data_t* ObsDataFromJson(nlohmann::json j); + +// OBS module data path helper +std::string get_obs_module_data_path_str(); + +// Schedule a task on the OBS task thread +void ScheduleOBSTask(std::function task); + +void InitThreadPool(); +void DestroyThreadPool(); + +struct obs_data_deleter { + void operator()(obs_data_t* p) const { + if (p) + obs_data_release(p); + } +}; + +using ObsDataPtr = std::unique_ptr; diff --git a/src/17live/utility/CustomCalendarWidget.cpp b/src/17live/utility/CustomCalendarWidget.cpp index 27e5e61..f8dab1a 100644 --- a/src/17live/utility/CustomCalendarWidget.cpp +++ b/src/17live/utility/CustomCalendarWidget.cpp @@ -2,19 +2,18 @@ #include -#include "plugin-support.h" #include "moc_CustomCalendarWidget.cpp" +#include "plugin-support.h" -CustomCalendarWidget::CustomCalendarWidget(const QDate& minDate, const QDate& maxDate, QWidget* parent) - : QCalendarWidget(parent) -{ +CustomCalendarWidget::CustomCalendarWidget(const QDate& minDate, const QDate& maxDate, + QWidget* parent) + : QCalendarWidget(parent) { setMinimumDate(minDate); setMaximumDate(maxDate); connect(this, &QCalendarWidget::currentPageChanged, this, [this]() { updateCells(); }); } -void CustomCalendarWidget::paintCell(QPainter* painter, const QRect& rect, QDate date) const -{ +void CustomCalendarWidget::paintCell(QPainter* painter, const QRect& rect, QDate date) const { if (date < minimumDate() || date > maximumDate()) { painter->save(); // painter->fillRect(rect, QColor(150, 150, 150)); diff --git a/src/17live/utility/CustomCalendarWidget.hpp b/src/17live/utility/CustomCalendarWidget.hpp index 62cf08b..68fba6d 100644 --- a/src/17live/utility/CustomCalendarWidget.hpp +++ b/src/17live/utility/CustomCalendarWidget.hpp @@ -1,15 +1,16 @@ #pragma once #include +#include #include -#include #include -#include +#include class CustomCalendarWidget : public QCalendarWidget { Q_OBJECT -public: + public: CustomCalendarWidget(const QDate& minDate, const QDate& maxDate, QWidget* parent = nullptr); -protected: - virtual void paintCell(QPainter *painter, const QRect &rect, QDate date) const override; + + protected: + virtual void paintCell(QPainter* painter, const QRect& rect, QDate date) const override; }; diff --git a/src/17live/utility/DownloadWorker.hpp b/src/17live/utility/DownloadWorker.hpp index e783d86..b54e1d9 100644 --- a/src/17live/utility/DownloadWorker.hpp +++ b/src/17live/utility/DownloadWorker.hpp @@ -22,6 +22,6 @@ class DownloadWorker : public QObject { private: QString downloadUrl; QString filePath; - bool canceled; + bool canceled = false; QMutex mutex; }; diff --git a/src/17live/utility/Meta.cpp b/src/17live/utility/Meta.cpp index 8073e42..0dba58f 100644 --- a/src/17live/utility/Meta.cpp +++ b/src/17live/utility/Meta.cpp @@ -1,5 +1,7 @@ #include "Meta.hpp" +#include + #include #include #include @@ -9,7 +11,7 @@ #include #include "Common.hpp" -#include "obs-module.h" +#include "plugin-support.h" using Json = nlohmann::json; @@ -92,10 +94,10 @@ bool JsonToOneSevenLiveMetaData(const Json& json, OneSevenLiveMetaData& metaData return true; } catch (const std::exception& e) { - blog(LOG_ERROR, "Exception in JsonToOneSevenLiveMetaData: %s", e.what()); + obs_log(LOG_ERROR, "Exception in JsonToOneSevenLiveMetaData: %s", e.what()); return false; } catch (...) { - blog(LOG_ERROR, "Unknown exception in JsonToOneSevenLiveMetaData"); + obs_log(LOG_ERROR, "Unknown exception in JsonToOneSevenLiveMetaData"); return false; } } @@ -163,10 +165,10 @@ Json OneSevenLiveMetaDataToJson(const OneSevenLiveMetaData& metaData) { return json; } catch (const std::exception& e) { - blog(LOG_ERROR, "Exception in OneSevenLiveMetaDataToJson: %s", e.what()); + obs_log(LOG_ERROR, "Exception in OneSevenLiveMetaDataToJson: %s", e.what()); return Json(); } catch (...) { - blog(LOG_ERROR, "Unknown exception in OneSevenLiveMetaDataToJson"); + obs_log(LOG_ERROR, "Unknown exception in OneSevenLiveMetaDataToJson"); return Json(); } } @@ -190,7 +192,7 @@ bool LoadMetaData() { } return true; } catch (const Json::parse_error& e) { - blog(LOG_ERROR, "JSON parse error in LoadMetaData: %s", e.what()); + obs_log(LOG_ERROR, "JSON parse error in LoadMetaData: %s", e.what()); return false; } } diff --git a/src/17live/utility/RemoteTextThread.cpp b/src/17live/utility/RemoteTextThread.cpp index aaa61e1..a24ff51 100644 --- a/src/17live/utility/RemoteTextThread.cpp +++ b/src/17live/utility/RemoteTextThread.cpp @@ -49,6 +49,22 @@ static size_t binary_write(char *ptr, size_t size, size_t nmemb, std::vector *cancelled = static_cast *>(clientp); + if (cancelled->load()) { + return 1; // Return non-zero to abort transfer + } + } + return 0; +} + void RemoteTextThread::run() { char error[CURL_ERROR_SIZE]; CURLcode code; @@ -100,9 +116,24 @@ void RemoteTextThread::run() { curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, postData.c_str()); } + curl_easy_setopt(curl.get(), CURLOPT_XFERINFOFUNCTION, progress_callback); + if (externalCancel) { + curl_easy_setopt(curl.get(), CURLOPT_XFERINFODATA, externalCancel); + } else { + curl_easy_setopt(curl.get(), CURLOPT_XFERINFODATA, &m_isCancelled); + } + curl_easy_setopt(curl.get(), CURLOPT_NOPROGRESS, 0L); + code = curl_easy_perform(curl.get()); + + if (m_isCancelled.load()) { + // If cancelled, don't emit results + curl_slist_free_all(header); + return; + } + if (code != CURLE_OK) { - // blog(LOG_WARNING, "RemoteTextThread: HTTP request failed. %s [url: %s]", + // obs_log(LOG_WARNING, "RemoteTextThread: HTTP request failed. %s [url: %s]", // strlen(error) ? error : curl_easy_strerror(code), url.c_str()); if (isImageRequest) { emit ImageResult(QByteArray(), QString::fromUtf8(error)); @@ -141,7 +172,7 @@ static size_t header_write(char *ptr, size_t size, size_t nmemb, vector bool GetRemoteFile(const char *url, std::string &str, std::string &error, long *responseCode, const char *contentType, std::string request_type, const char *postData, std::vector extraHeaders, std::string *signature, int timeoutSec, - bool fail_on_error, int postDataSize) { + bool fail_on_error, int postDataSize, std::atomic *cancelFlag) { vector header_in_list; char error_in[CURL_ERROR_SIZE]; CURLcode code = CURLE_FAILED_INIT; @@ -206,6 +237,12 @@ bool GetRemoteFile(const char *url, std::string &str, std::string &error, long * curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, postData); } + if (cancelFlag) { + curl_easy_setopt(curl.get(), CURLOPT_XFERINFOFUNCTION, progress_callback); + curl_easy_setopt(curl.get(), CURLOPT_XFERINFODATA, cancelFlag); + curl_easy_setopt(curl.get(), CURLOPT_NOPROGRESS, 0L); + } + code = curl_easy_perform(curl.get()); if (responseCode) curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, responseCode); diff --git a/src/17live/utility/RemoteTextThread.hpp b/src/17live/utility/RemoteTextThread.hpp index 504e6d5..8504128 100644 --- a/src/17live/utility/RemoteTextThread.hpp +++ b/src/17live/utility/RemoteTextThread.hpp @@ -33,6 +33,7 @@ class RemoteTextThread : public QThread { int timeoutSec = 0; bool isImageRequest = false; + std::atomic *externalCancel = nullptr; void run() override; @@ -60,6 +61,34 @@ class RemoteTextThread : public QThread { extraHeaders(std::move(extraHeaders_)), timeoutSec(timeoutSec_), isImageRequest(isImageRequest_) {} + + inline RemoteTextThread(std::string url_, std::vector &&extraHeaders_, + std::string contentType_, std::string postData_, int timeoutSec_, + bool isImageRequest_, std::atomic *externalCancel_) + : url(url_), + contentType(contentType_), + postData(postData_), + extraHeaders(std::move(extraHeaders_)), + timeoutSec(timeoutSec_), + isImageRequest(isImageRequest_), + externalCancel(externalCancel_) {} + + inline RemoteTextThread(std::string url_, std::string contentType_, std::string postData_, + int timeoutSec_, bool isImageRequest_, + std::atomic *externalCancel_) + : url(url_), + contentType(contentType_), + postData(postData_), + timeoutSec(timeoutSec_), + isImageRequest(isImageRequest_), + externalCancel(externalCancel_) {} + + void cancel() { + m_isCancelled.store(true); + } + + private: + std::atomic m_isCancelled{false}; }; bool GetRemoteFile(const char *url, std::string &str, std::string &error, @@ -67,4 +96,4 @@ bool GetRemoteFile(const char *url, std::string &str, std::string &error, std::string request_type = "", const char *postData = nullptr, std::vector extraHeaders = std::vector(), std::string *signature = nullptr, int timeoutSec = 0, bool fail_on_error = true, - int postDataSize = 0); + int postDataSize = 0, std::atomic *cancelFlag = nullptr); diff --git a/src/17live/websocket/OneSevenLiveWebsocketClient.cpp b/src/17live/websocket/OneSevenLiveWebsocketClient.cpp new file mode 100644 index 0000000..39a1b26 --- /dev/null +++ b/src/17live/websocket/OneSevenLiveWebsocketClient.cpp @@ -0,0 +1,703 @@ +#include "OneSevenLiveWebsocketClient.hpp" + +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#include +#include +#endif +#include + +#include +#include +#include + +#include "WebsocketUtils.hpp" +#include "plugin-support.h" + +#ifdef _WIN32 +struct TLSHandles { + HINTERNET hSession{nullptr}; + HINTERNET hConnect{nullptr}; + HINTERNET hRequest{nullptr}; + HINTERNET hWebSocket{nullptr}; + + ~TLSHandles() { + if (hWebSocket) { + WinHttpWebSocketShutdown(hWebSocket, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, + 0); + WinHttpCloseHandle(hWebSocket); + } + if (hRequest) + WinHttpCloseHandle(hRequest); + if (hConnect) + WinHttpCloseHandle(hConnect); + if (hSession) + WinHttpCloseHandle(hSession); + } +}; +#else +struct TLSHandles { + mbedtls_ssl_context ssl; + mbedtls_net_context server_fd; + mbedtls_ssl_config conf; + mbedtls_ctr_drbg_context ctr_drbg; + mbedtls_entropy_context entropy; + mbedtls_x509_crt cacert; + + TLSHandles() { + mbedtls_ssl_init(&ssl); + mbedtls_net_init(&server_fd); + mbedtls_ssl_config_init(&conf); + mbedtls_ctr_drbg_init(&ctr_drbg); + mbedtls_entropy_init(&entropy); + mbedtls_x509_crt_init(&cacert); + } + + ~TLSHandles() { + mbedtls_ssl_close_notify(&ssl); + mbedtls_ssl_free(&ssl); + mbedtls_net_free(&server_fd); + mbedtls_ssl_config_free(&conf); + mbedtls_ctr_drbg_free(&ctr_drbg); + mbedtls_entropy_free(&entropy); + mbedtls_x509_crt_free(&cacert); + } +}; +#endif + +OneSevenLiveWebsocketClient::OneSevenLiveWebsocketClient(QObject* parent) : QObject(parent) {} + +OneSevenLiveWebsocketClient::~OneSevenLiveWebsocketClient() { + disconnect(); +} + +void OneSevenLiveWebsocketClient::setOpenCallback(const std::function& cb) { + onOpen = cb; +} + +void OneSevenLiveWebsocketClient::setMessageCallback( + const std::function& cb) { + onMessage = cb; +} + +void OneSevenLiveWebsocketClient::setCloseCallback(const std::function& cb) { + onClose = cb; +} + +void OneSevenLiveWebsocketClient::setErrorCallback( + const std::function& cb) { + onError = cb; +} + +void OneSevenLivewebsocketClient_connectUrl_parse_helper(QUrl& parsed, QString& host, QString& port, + QString& path) { + host = parsed.host(); + QString pth = parsed.path(); + if (pth.isEmpty()) + pth = "/"; + if (parsed.hasQuery()) { + path = pth + "?" + parsed.query(); + } else { + path = pth; + } + int p = parsed.port(443); + port = QString::number(p); +} + +void OneSevenLiveWebsocketClient::connectUrl(const QString& url) { + QUrl parsed(url.trimmed()); + QString host; + QString port; + QString path; + OneSevenLivewebsocketClient_connectUrl_parse_helper(parsed, host, port, path); + startThread(host, port, path); +} + +void OneSevenLiveWebsocketClient::disconnect() { + stopThread(); +} + +void OneSevenLiveWebsocketClient::disconnectAsync() { + running.store(false); + if (tls) { +#ifdef _WIN32 + if (tls->hWebSocket) + WinHttpWebSocketShutdown(tls->hWebSocket, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, + nullptr, 0); +#else + mbedtls_ssl_close_notify(&tls->ssl); +#endif + } + // Do not join here to avoid blocking UI; thread will exit and self-clean +} + +bool OneSevenLiveWebsocketClient::isConnected() const { + return connected.load(); +} + +void OneSevenLiveWebsocketClient::startThread(const QString& host, const QString& port, + const QString& path) { + // Ensure previous thread is joined and resources are cleaned up + stopThread(); + + if (running.load()) + return; + running.store(true); + th = std::thread(&OneSevenLiveWebsocketClient::threadFunc, this, host, port, path); +} + +void OneSevenLiveWebsocketClient::stopThread() { + running.store(false); + // Proactively signal TLS to close to unblock any pending reads + if (tls) { +#ifdef _WIN32 + if (tls->hWebSocket) + WinHttpWebSocketShutdown(tls->hWebSocket, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, + nullptr, 0); +#else + mbedtls_ssl_close_notify(&tls->ssl); + mbedtls_net_free(&tls->server_fd); +#endif + } + try { + if (th.joinable()) { + th.join(); + } + } catch (...) { + // Swallow thread join errors to avoid terminate during shutdown + } + cleanupTLS(); + if (connected.load()) { + // If we are stopping (e.g. destruction or explicit disconnect), + // we should not invoke async callbacks if the object might be destroyed soon. + // However, standard disconnect() calls might expect a callback. + // To be safe in destruction scenarios, we should avoid QueuedConnection if we can't + // guarantee lifetime, but since we don't know if this is destruction or just stop, we rely + // on the caller to manage lifetime OR we can use QPointer in the lambda capture if we + // inherited from QObject (which we do). + + // Better yet: invokeMethod with Qt::DirectConnection if we are in the same thread? + // No, stopThread can be called from any thread. + // Let's use QPointer protection pattern here too. + + QPointer self(this); + QMetaObject::invokeMethod( + this, + [self]() { + if (self && self->onClose) + self->onClose(); + }, + Qt::QueuedConnection); + + connected.store(false); + } +} + +#ifdef _WIN32 +void OneSevenLiveWebsocketClient::threadFunc(const QString& host, const QString& port, + const QString& path) { + tls = std::make_unique(); + std::wstring ua = L"obs-17live/1.0"; + tls->hSession = WinHttpOpen(ua.c_str(), WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!tls->hSession) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("winhttp_open"); }, Qt::QueuedConnection); + stopThread(); + return; + } + std::wstring whost = host.toStdWString(); + int p = port.toInt(); + tls->hConnect = WinHttpConnect(tls->hSession, whost.c_str(), (INTERNET_PORT) p, 0); + if (!tls->hConnect) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("winhttp_connect"); }, Qt::QueuedConnection); + stopThread(); + return; + } + std::wstring wpath = path.toStdWString(); + tls->hRequest = + WinHttpOpenRequest(tls->hConnect, L"GET", wpath.c_str(), nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (!tls->hRequest) { + DWORD ec = GetLastError(); + if (onError) + QMetaObject::invokeMethod( + this, + [this, ec]() { onError(std::string("winhttp_openreq ") + std::to_string(ec)); }, + Qt::QueuedConnection); + stopThread(); + return; + } + if (!WinHttpSetOption(tls->hRequest, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0)) { + DWORD ec = GetLastError(); + if (onError) + QMetaObject::invokeMethod( + this, + [this, ec]() { + onError(std::string("winhttp_setopt_upgrade ") + std::to_string(ec)); + }, + Qt::QueuedConnection); + stopThread(); + return; + } +#if defined(WINHTTP_OPTION_SECURE_PROTOCOLS) && defined(WINHTTP_PROTOCOL_FLAG_TLS1_2) + DWORD sp = WINHTTP_PROTOCOL_FLAG_TLS1_2; + WinHttpSetOption(tls->hRequest, WINHTTP_OPTION_SECURE_PROTOCOLS, &sp, sizeof(sp)); +#endif + std::string wsKey = generateWebSocketKey(); + std::wstring hdr = L"Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: "; + hdr += QString::fromStdString(wsKey).toStdWString(); + hdr += L"\r\nSec-WebSocket-Version: 13\r\nOrigin: https://www.twitch.tv\r\n"; + if (!WinHttpAddRequestHeaders(tls->hRequest, hdr.c_str(), (DWORD) hdr.size(), + WINHTTP_ADDREQ_FLAG_ADD)) { + DWORD ec = GetLastError(); + if (onError) + QMetaObject::invokeMethod( + this, + [this, ec]() { onError(std::string("winhttp_addhdr ") + std::to_string(ec)); }, + Qt::QueuedConnection); + stopThread(); + return; + } + if (!WinHttpSendRequest(tls->hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) { + DWORD ec = GetLastError(); + if (onError) + QMetaObject::invokeMethod( + this, [this, ec]() { onError(std::string("winhttp_send ") + std::to_string(ec)); }, + Qt::QueuedConnection); + stopThread(); + return; + } + if (!WinHttpReceiveResponse(tls->hRequest, nullptr)) { + DWORD ec = GetLastError(); + if (onError) + QMetaObject::invokeMethod( + this, [this, ec]() { onError(std::string("winhttp_resp ") + std::to_string(ec)); }, + Qt::QueuedConnection); + stopThread(); + return; + } + DWORD status = 0; + DWORD len = sizeof(status); + if (!WinHttpQueryHeaders(tls->hRequest, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &len, + WINHTTP_NO_HEADER_INDEX)) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("winhttp_status"); }, Qt::QueuedConnection); + stopThread(); + return; + } + if (status != 101) { + if (onError) + QMetaObject::invokeMethod(this, [this]() { onError("ws_101"); }, Qt::QueuedConnection); + stopThread(); + return; + } + tls->hWebSocket = WinHttpWebSocketCompleteUpgrade(tls->hRequest, 0); + if (!tls->hWebSocket) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("ws_complete"); }, Qt::QueuedConnection); + stopThread(); + return; + } + connected.store(true); + if (onOpen) + QMetaObject::invokeMethod(this, [this]() { onOpen(); }, Qt::QueuedConnection); + std::vector buf(65536); + while (running.load()) { + DWORD rd = 0; + WINHTTP_WEB_SOCKET_BUFFER_TYPE tp = WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE; + DWORD r = + WinHttpWebSocketReceive(tls->hWebSocket, buf.data(), (DWORD) buf.size(), &rd, &tp); + if (r == ERROR_SUCCESS) { + if (tp == WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE) { + std::string payload(buf.data(), buf.data() + rd); + if (onMessage) + QMetaObject::invokeMethod( + this, [this, payload]() { onMessage(payload); }, Qt::QueuedConnection); + } else if (tp == WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE || + tp == WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE || + tp == WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE) { + // Ignore non-text frames or fragments for now + } else if (tp == WINHTTP_WEB_SOCKET_CLOSE_BUFFER_TYPE) { + break; + } + } else { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("ws_recv"); }, Qt::QueuedConnection); + break; + } + } + stopThread(); +} +#else +#include + +void OneSevenLiveWebsocketClient::threadFunc(const QString& host, const QString& port, + const QString& path) { + tls = std::make_unique(); + const char* pers = "ws_client"; + int ret = mbedtls_ctr_drbg_seed(&tls->ctr_drbg, mbedtls_entropy_func, &tls->entropy, + (const unsigned char*) pers, strlen(pers)); + if (ret != 0) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("rng_init"); }, Qt::QueuedConnection); + running.store(false); + return; + } + ret = mbedtls_ssl_config_defaults(&tls->conf, MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); + if (ret != 0) { + if (onError) + QMetaObject::invokeMethod(this, [this]() { onError("ssl_cfg"); }, Qt::QueuedConnection); + running.store(false); + return; + } + mbedtls_ssl_conf_authmode(&tls->conf, MBEDTLS_SSL_VERIFY_NONE); + mbedtls_ssl_conf_ca_chain(&tls->conf, nullptr, nullptr); + mbedtls_ssl_conf_rng(&tls->conf, mbedtls_ctr_drbg_random, &tls->ctr_drbg); + ret = mbedtls_net_connect(&tls->server_fd, host.toUtf8().constData(), port.toUtf8().constData(), + MBEDTLS_NET_PROTO_TCP); + if (ret != 0) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("tcp_connect"); }, Qt::QueuedConnection); + running.store(false); + return; + } + ret = mbedtls_ssl_setup(&tls->ssl, &tls->conf); + if (ret != 0) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("ssl_setup"); }, Qt::QueuedConnection); + running.store(false); + return; + } + mbedtls_net_set_nonblock(&tls->server_fd); + mbedtls_ssl_set_bio(&tls->ssl, &tls->server_fd, mbedtls_net_send, mbedtls_net_recv, nullptr); + mbedtls_ssl_set_hostname(&tls->ssl, host.toUtf8().constData()); + while (running.load()) { + ret = mbedtls_ssl_handshake(&tls->ssl); + if (ret == 0) { + break; + } + if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) { + // Wait for socket + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (ret == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (ret == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + continue; + } + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("tls_handshake"); }, Qt::QueuedConnection); + running.store(false); + return; + } + if (!running.load()) { + running.store(false); + return; + } + std::string wsKey = generateWebSocketKey(); + std::string req = "GET " + path.toStdString() + + " HTTP/1.1\r\n" + "Host: " + + host.toStdString() + + "\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: " + + wsKey + + "\r\n" + "Sec-WebSocket-Version: 13\r\n" + "User-Agent: obs-17live/1.0\r\n\r\n"; + if (!sendTLS(req)) { + if (onError) + QMetaObject::invokeMethod(this, [this]() { onError("ws_req"); }, Qt::QueuedConnection); + running.store(false); + return; + } + std::string resp; + char buf[1024]; + int tr = 0; + int maxR = 4096; + while (tr < maxR) { + int r = mbedtls_ssl_read(&tls->ssl, (unsigned char*) buf, sizeof(buf) - 1); + if (r > 0) { + buf[r] = '\0'; + resp.append(buf, r); + tr += r; + if (resp.find("\r\n\r\n") != std::string::npos) + break; + } else if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) { + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (r == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (r == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + continue; + } else { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("ws_resp"); }, Qt::QueuedConnection); + running.store(false); + return; + } + } + if (resp.find("101 Switching Protocols") == std::string::npos) { + if (onError) + QMetaObject::invokeMethod(this, [this]() { onError("ws_101"); }, Qt::QueuedConnection); + running.store(false); + return; + } + connected.store(true); + if (onOpen) + QMetaObject::invokeMethod(this, [this]() { onOpen(); }, Qt::QueuedConnection); + while (running.load()) { + unsigned char h[2]; + int r = mbedtls_ssl_read(&tls->ssl, h, 2); + if (r == 2) { + unsigned char opcode = h[0] & 0x0F; + uint64_t len = h[1] & 0x7F; + if (len == 126) { + unsigned char ext[2]; + // Loop to ensure we read 2 bytes + size_t read_bytes = 0; + while (read_bytes < 2) { + r = mbedtls_ssl_read(&tls->ssl, ext + read_bytes, 2 - read_bytes); + if (r > 0) + read_bytes += r; + else if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) { + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (r == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (r == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } + continue; + } else + break; + } + if (read_bytes != 2) + break; + len = (ext[0] << 8) | ext[1]; + } else if (len == 127) { + unsigned char ext[8]; + size_t read_bytes = 0; + while (read_bytes < 8) { + r = mbedtls_ssl_read(&tls->ssl, ext + read_bytes, 8 - read_bytes); + if (r > 0) + read_bytes += r; + else if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) { + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (r == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (r == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } + continue; + } else + break; + } + if (read_bytes != 8) + break; + len = 0; + for (int i = 0; i < 8; i++) + len = (len << 8) | ext[i]; + } + if (len > 0 && len < 65536) { + std::string payload; + payload.resize(len); + size_t br = 0; + while (br < len) { + r = mbedtls_ssl_read(&tls->ssl, (unsigned char*) payload.data() + br, len - br); + if (r > 0) + br += r; + else if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) { + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (r == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (r == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } + continue; + } else { + br = 0; + break; + } + } + if (br == len) { + if (opcode == 0x1) { + if (onMessage) + QMetaObject::invokeMethod( + this, [this, payload]() { onMessage(payload); }, + Qt::QueuedConnection); + } else if (opcode == 0x8) { + int code = 1000; + std::string reason; + if (payload.size() >= 2) { + code = ((unsigned char) payload[0] << 8) | (unsigned char) payload[1]; + if (payload.size() > 2) + reason.assign(payload.data() + 2, payload.size() - 2); + } + if (code != 1000) { + obs_log(LOG_INFO, + "[Websocket Client] Close received: code=%d reason=%s", code, + reason.c_str()); + } + if (code != 1000 && onError) { + std::string msg = + std::string("ws_close ") + std::to_string(code) + + (reason.empty() ? std::string("") : std::string(" ") + reason); + QMetaObject::invokeMethod( + this, [this, msg]() { onError(msg); }, Qt::QueuedConnection); + } + break; + } else if (opcode == 0x9) { + std::string pl = payload; + if (pl.size() > 125) + pl.clear(); + unsigned char k[4]; + mbedtls_ctr_drbg_random(&tls->ctr_drbg, k, 4); + std::string f; + f.push_back((char) 0x8A); + f.push_back((char) (0x80 | (unsigned char) pl.size())); + f.append((char*) k, 4); + for (size_t i = 0; i < pl.size(); i++) + f.push_back(pl[i] ^ k[i % 4]); + sendTLS(f); + } + } + } + } else if (r == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) { + obs_log(LOG_INFO, "[Websocket Client] Peer close notify received"); + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("peer_close_notify"); }, Qt::QueuedConnection); + break; + } else if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) { + int fd = tls->server_fd.fd; + if (fd >= 0) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(fd, &fds); + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 100000; // 100ms + select(fd + 1, (r == MBEDTLS_ERR_SSL_WANT_READ) ? &fds : NULL, + (r == MBEDTLS_ERR_SSL_WANT_WRITE) ? &fds : NULL, NULL, &tv); + } + continue; + } else if (r < 0) { + if (onError) + QMetaObject::invokeMethod( + this, [this]() { onError("tls_read"); }, Qt::QueuedConnection); + break; + } else { + break; + } + } + + if (connected.load()) { + if (onClose) + QMetaObject::invokeMethod(this, [this]() { onClose(); }, Qt::QueuedConnection); + connected.store(false); + } + running.store(false); +} +#endif + +bool OneSevenLiveWebsocketClient::sendTLS(const std::string& data) { +#ifdef _WIN32 + return false; +#else + if (!tls) + return false; + int ret = mbedtls_ssl_write(&tls->ssl, (const unsigned char*) data.c_str(), data.length()); + return ret >= 0; +#endif +} + +void OneSevenLiveWebsocketClient::sendText(const QString& text) { + if (!connected.load()) + return; + std::string m = text.toStdString(); +#ifdef _WIN32 + if (!tls || !tls->hWebSocket) + return; + WinHttpWebSocketSend(tls->hWebSocket, WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE, + (void*) m.data(), (DWORD) m.size()); +#else + std::string f; + f.push_back((char) 0x81); + unsigned char k[4]; + if (!tls) + return; + mbedtls_ctr_drbg_random(&tls->ctr_drbg, k, 4); + if (m.length() <= 125) { + f.push_back((char) (0x80 | (unsigned char) m.length())); + } else if (m.length() <= 65535) { + f.push_back((char) (0x80 | 126)); + f.push_back((char) ((m.length() >> 8) & 0xFF)); + f.push_back((char) (m.length() & 0xFF)); + } else { + return; + } + f.append((char*) k, 4); + for (size_t i = 0; i < m.length(); i++) + f.push_back(m[i] ^ k[i % 4]); + sendTLS(f); +#endif +} + +void OneSevenLiveWebsocketClient::cleanupTLS() { + tls.reset(); +} diff --git a/src/17live/websocket/OneSevenLiveWebsocketClient.hpp b/src/17live/websocket/OneSevenLiveWebsocketClient.hpp new file mode 100644 index 0000000..b082b6a --- /dev/null +++ b/src/17live/websocket/OneSevenLiveWebsocketClient.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct TLSHandles; + +class OneSevenLiveWebsocketClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveWebsocketClient(QObject* parent = nullptr); + ~OneSevenLiveWebsocketClient(); + + void connectUrl(const QString& url); + void disconnect(); + void disconnectAsync(); + bool isConnected() const; + void sendText(const QString& text); + + void setOpenCallback(const std::function& cb); + void setMessageCallback(const std::function& cb); + void setCloseCallback(const std::function& cb); + void setErrorCallback(const std::function& cb); + + private: + void startThread(const QString& host, const QString& port, const QString& path); + void stopThread(); + void threadFunc(const QString& host, const QString& port, const QString& path); + bool sendTLS(const std::string& data); + void cleanupTLS(); + + std::atomic connected{false}; + std::atomic running{false}; + std::thread th; + + std::unique_ptr tls; + + std::function onOpen; + std::function onMessage; + std::function onClose; + std::function onError; +}; diff --git a/src/17live/websocket/OneSevenLiveWebsocketServer.cpp b/src/17live/websocket/OneSevenLiveWebsocketServer.cpp new file mode 100644 index 0000000..a4a9dfd --- /dev/null +++ b/src/17live/websocket/OneSevenLiveWebsocketServer.cpp @@ -0,0 +1,584 @@ +#include "OneSevenLiveWebsocketServer.hpp" + +#include + +#include +#include +#include +#include +#include + +// System headers for socket operations +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#include +#endif +#include + +#include "plugin-support.h" + +OneSevenLiveWebsocketServer::OneSevenLiveWebsocketServer(const std::string& host, int port) + : host_(host == "localhost" ? "127.0.0.1" : host), port_(port), running_(false) {} + +OneSevenLiveWebsocketServer::~OneSevenLiveWebsocketServer() { + obs_log(LOG_INFO, "[17Live WebSocket Server] Starting WebSocket server destruction"); + + // Ensure server is completely stopped and thread properly terminated + stop(); + + // Additional safety check: ensure thread has completely finished + if (server_thread_ && server_thread_->joinable()) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Thread still joinable in destructor, forcing thread " + "termination wait"); + server_thread_->join(); + } + + obs_log(LOG_INFO, "[17Live WebSocket Server] WebSocket server successfully destroyed"); +} + +bool OneSevenLiveWebsocketServer::start() { + if (running_) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Server already running."); + return true; + } + + try { + // If port is 0, find an available port first + int actual_port = port_; + if (port_ == 0) { + actual_port = getAvailablePort(); + if (actual_port == 0) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to find available port"); + return false; + } + port_ = actual_port; + obs_log(LOG_INFO, "[17Live WebSocket Server] Using auto-assigned port: %d", + actual_port); + } + + // Create WebSocket server instance + server_ = std::make_unique(); + + // Initialize ASIO + server_->init_asio(); + server_->set_reuse_addr(true); + + // Set up logging + server_->set_access_channels(websocketpp::log::alevel::none); + server_->clear_access_channels(websocketpp::log::alevel::all); + server_->clear_error_channels(websocketpp::log::elevel::all); + + // Register handlers + server_->set_open_handler([this](websocketpp::connection_hdl hdl) { onConnection(hdl); }); + + server_->set_close_handler([this](websocketpp::connection_hdl hdl) { onClose(hdl); }); + + server_->set_message_handler( + [this](websocketpp::connection_hdl hdl, websocketpp_server::message_ptr msg) { + onMessage(hdl, msg); + }); + + server_->set_fail_handler([this](websocketpp::connection_hdl hdl) { onFail(hdl); }); + + // Start server in new thread to avoid blocking main thread + server_thread_ = std::make_unique([this, actual_port]() { + try { + obs_log(LOG_INFO, "[17Live WebSocket Server] Starting server on %s:%d", + host_.c_str(), actual_port); + + // Listen on specified port + server_->listen(actual_port); + server_->start_accept(); + + obs_log(LOG_INFO, "[17Live WebSocket Server] Server started successfully on %s:%d", + host_.c_str(), actual_port); + + // Run the IO service + server_->run(); + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Exception during server startup: %s", + e.what()); + running_ = false; + } catch (...) { + obs_log(LOG_ERROR, + "[17Live WebSocket Server] Unknown exception during server startup"); + running_ = false; + } + }); + + // Wait a bit to see if server can start successfully + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + running_ = true; + obs_log(LOG_INFO, "[17Live WebSocket Server] Server thread started successfully"); + + return true; + + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to create server: %s", e.what()); + return false; + } +} + +void OneSevenLiveWebsocketServer::stop() { + if (!running_) { + return; + } + + obs_log(LOG_INFO, "[17Live WebSocket Server] Stopping WebSocket server"); + + running_ = false; + + // Stop the server + if (server_) { + server_->stop(); + } + + // Close all client connections + { + std::lock_guard lock(clients_mutex_); + for (auto& pair : clients_) { + try { + server_->close(pair.second, websocketpp::close::status::going_away, + "Server shutting down"); + } catch (...) { + // Ignore errors during shutdown + } + } + clients_.clear(); + client_ips_.clear(); + hdl_to_client_id_.clear(); + } + + // Wait for server thread to finish + if (server_thread_ && server_thread_->joinable()) { + server_thread_->join(); + } + + obs_log(LOG_INFO, "[17Live WebSocket Server] WebSocket server stopped"); +} + +bool OneSevenLiveWebsocketServer::is_running() const { + return running_; +} + +int OneSevenLiveWebsocketServer::getPort() const { + return port_; +} + +void OneSevenLiveWebsocketServer::broadcastMessage(const std::string& message) { + if (!validate_message_size(message)) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Message too large for broadcast: %zu bytes", + message.size()); + return; + } + + std::lock_guard lock(clients_mutex_); + + for (auto it = clients_.begin(); it != clients_.end();) { + try { + server_->send(it->second, message, websocketpp::frame::opcode::text); + ++it; + } catch (const std::exception& e) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Failed to send message to client %s: %s", + it->first.c_str(), e.what()); + + // Remove failed client + std::string hdl_str = hdl_to_string(it->second); + hdl_to_client_id_.erase(hdl_str); + client_ips_.erase(it->first); + it = clients_.erase(it); + } + } +} + +void OneSevenLiveWebsocketServer::sendMessageToClient(const std::string& clientId, + const std::string& message) { + if (!validate_message_size(message)) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Message too large for client %s: %zu bytes", + clientId.c_str(), message.size()); + return; + } + + std::lock_guard lock(clients_mutex_); + + auto it = clients_.find(clientId); + if (it != clients_.end()) { + try { + server_->send(it->second, message, websocketpp::frame::opcode::text); + } catch (const std::exception& e) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Failed to send message to client %s: %s", + clientId.c_str(), e.what()); + + // Remove failed client + std::string hdl_str = hdl_to_string(it->second); + hdl_to_client_id_.erase(hdl_str); + client_ips_.erase(it->first); + clients_.erase(it); + } + } else { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Client %s not found", clientId.c_str()); + } +} + +size_t OneSevenLiveWebsocketServer::getConnectedClientsCount() const { + std::lock_guard lock(clients_mutex_); + return clients_.size(); +} + +std::vector OneSevenLiveWebsocketServer::getConnectedClientIds() const { + std::lock_guard lock(clients_mutex_); + + std::vector clientIds; + clientIds.reserve(clients_.size()); + + for (const auto& pair : clients_) { + clientIds.push_back(pair.first); + } + + return clientIds; +} + +void OneSevenLiveWebsocketServer::setMessageCallback(const MessageCallback& callback) { + std::lock_guard lock(callback_mutex_); + message_callback_ = callback; +} + +void OneSevenLiveWebsocketServer::setConnectionCallback(const ConnectionCallback& callback) { + std::lock_guard lock(callback_mutex_); + connection_callback_ = callback; +} + +bool OneSevenLiveWebsocketServer::check_rate_limit(const std::string& client_ip) { + std::lock_guard lock(rate_limit_mutex_); + + auto now = std::chrono::steady_clock::now(); + auto& timestamps = rate_limit_map_[client_ip]; + + // Remove old timestamps + timestamps.erase( + std::remove_if( + timestamps.begin(), timestamps.end(), + [now](const std::chrono::steady_clock::time_point& timestamp) { + return std::chrono::duration_cast(now - timestamp).count() > + RATE_LIMIT_WINDOW_SECONDS; + }), + timestamps.end()); + + // Check if rate limit exceeded + if (timestamps.size() >= RATE_LIMIT_MESSAGES) { + return false; + } + + // Add current timestamp + timestamps.push_back(now); + return true; +} + +bool OneSevenLiveWebsocketServer::validate_message_size(const std::string& message) const { + return message.size() <= MAX_MESSAGE_SIZE; +} + +std::string OneSevenLiveWebsocketServer::generate_client_id() { + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + + std::stringstream ss; + ss << "client_"; + for (int i = 0; i < 16; ++i) { + ss << std::hex << dis(gen); + } + + return ss.str(); +} + +std::string OneSevenLiveWebsocketServer::get_client_ip(websocketpp::connection_hdl hdl) { + try { + auto con = server_->get_con_from_hdl(hdl); + return con->get_remote_endpoint(); + } catch (const std::exception& e) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Failed to get client IP: %s", e.what()); + return "unknown"; + } +} + +std::string OneSevenLiveWebsocketServer::hdl_to_string(websocketpp::connection_hdl hdl) { + // Convert connection_hdl to a unique string identifier + // Since connection_hdl is std::weak_ptr, we can use the pointer address + try { + if (auto locked = hdl.lock()) { + // Use the raw pointer address as a unique identifier + std::stringstream ss; + ss << "hdl_" << locked.get(); + return ss.str(); + } + } catch (const std::exception& e) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Failed to convert hdl to string: %s", + e.what()); + } + return "hdl_unknown"; +} + +int OneSevenLiveWebsocketServer::getAvailablePort() const { + int available_port = 0; + // Create a socket to find an available port (platform-specific) +#ifdef _WIN32 + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] WSAStartup failed for port detection"); + return 0; + } + + SOCKET sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sockfd == INVALID_SOCKET) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to create socket for port detection"); + WSACleanup(); + return 0; + } + + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = inet_addr(host_.c_str()); + addr.sin_port = 0; // Let system choose port + + if (bind(sockfd, (struct sockaddr*) &addr, sizeof(addr)) == SOCKET_ERROR) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to bind socket for port detection"); + closesocket(sockfd); + WSACleanup(); + return 0; + } + + int addr_len = sizeof(addr); + if (getsockname(sockfd, (struct sockaddr*) &addr, &addr_len) == SOCKET_ERROR) { + obs_log(LOG_ERROR, + "[17Live WebSocket Server] Failed to get socket name for port detection"); + closesocket(sockfd); + WSACleanup(); + return 0; + } + + available_port = ntohs(addr.sin_port); + closesocket(sockfd); + WSACleanup(); +#else + int sockfd = socket(AF_INET, SOCK_STREAM, 0); + if (sockfd < 0) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to create socket for port detection"); + return 0; + } + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = inet_addr(host_.c_str()); + addr.sin_port = 0; // Let system choose port + + if (bind(sockfd, (struct sockaddr*) &addr, sizeof(addr)) < 0) { + obs_log(LOG_ERROR, "[17Live WebSocket Server] Failed to bind socket for port detection"); + close(sockfd); + return 0; + } + + socklen_t addr_len = sizeof(addr); + if (getsockname(sockfd, (struct sockaddr*) &addr, &addr_len) < 0) { + obs_log(LOG_ERROR, + "[17Live WebSocket Server] Failed to get socket name for port detection"); + close(sockfd); + return 0; + } + + available_port = ntohs(addr.sin_port); + close(sockfd); +#endif + + obs_log(LOG_INFO, "[17Live WebSocket Server] Found available port: %d", available_port); + return available_port; +} + +void OneSevenLiveWebsocketServer::onConnection(websocketpp::connection_hdl hdl) { + if (!running_) { + return; + } + + std::string clientId = generate_client_id(); + std::string clientIp = get_client_ip(hdl); + + obs_log(LOG_DEBUG, "[17Live WebSocket Server] New connection: %s from %s", clientId.c_str(), + clientIp.c_str()); + + // Store client connection + { + std::lock_guard lock(clients_mutex_); + clients_[clientId] = hdl; + client_ips_[clientId] = clientIp; + std::string hdl_str = hdl_to_string(hdl); + hdl_to_client_id_[hdl_str] = clientId; + } + + // Send welcome message to newly connected client + try { + std::string welcomeMessage = + "Hello! Welcome to 17Live WebSocket Server. Connection established successfully."; + server_->send(hdl, welcomeMessage, websocketpp::frame::opcode::text); + } catch (const std::exception& e) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Failed to send welcome message to client %s: %s", + clientId.c_str(), e.what()); + } + + // Notify connection callback + { + std::lock_guard lock(callback_mutex_); + if (connection_callback_) { + connection_callback_(clientId, true); + } + } +} + +void OneSevenLiveWebsocketServer::closeAllClients() { + if (!running_ || !server_) { + return; + } + std::vector hdls; + { + std::lock_guard lock(clients_mutex_); + for (auto& pair : clients_) { + hdls.push_back(pair.second); + } + } + for (auto& hdl : hdls) { + try { + server_->close(hdl, websocketpp::close::status::normal, "Logout"); + } catch (...) { + } + } +} + +void OneSevenLiveWebsocketServer::onClose(websocketpp::connection_hdl hdl) { + std::string clientId; + + // Find client ID + { + std::lock_guard lock(clients_mutex_); + std::string hdl_str = hdl_to_string(hdl); + auto it = hdl_to_client_id_.find(hdl_str); + if (it != hdl_to_client_id_.end()) { + clientId = it->second; + + obs_log(LOG_DEBUG, "[17Live WebSocket Server] Client %s disconnected", + clientId.c_str()); + + // Remove client from connections + clients_.erase(clientId); + client_ips_.erase(clientId); + hdl_to_client_id_.erase(it); + } + } + + // Notify connection callback + if (!clientId.empty()) { + std::lock_guard lock(callback_mutex_); + if (connection_callback_) { + connection_callback_(clientId, false); + } + } +} + +void OneSevenLiveWebsocketServer::onMessage(websocketpp::connection_hdl hdl, + websocketpp_server::message_ptr msg) { + if (!running_) { + return; + } + + // Find client ID for this connection + std::string clientId; + std::string clientIp; + { + std::lock_guard lock(clients_mutex_); + std::string hdl_str = hdl_to_string(hdl); + auto it = hdl_to_client_id_.find(hdl_str); + if (it != hdl_to_client_id_.end()) { + clientId = it->second; + auto ip_it = client_ips_.find(clientId); + if (ip_it != client_ips_.end()) { + clientIp = ip_it->second; + } + } + } + + if (clientId.empty()) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Received message from unknown client"); + return; + } + + // Rate limiting check + if (!check_rate_limit(clientIp)) { + obs_log(LOG_WARNING, "[17Live WebSocket Server] Rate limit exceeded for client %s", + clientId.c_str()); + + try { + server_->close(hdl, websocketpp::close::status::policy_violation, + "Rate limit exceeded"); + } catch (...) { + // Ignore errors during forced close + } + return; + } + + // Message size validation + if (!validate_message_size(msg->get_payload())) { + obs_log(LOG_WARNING, + "[17Live WebSocket Server] Message too large from client %s: %zu bytes", + clientId.c_str(), msg->get_payload().size()); + return; + } + + // Notify message callback + { + std::lock_guard lock(callback_mutex_); + if (message_callback_) { + message_callback_(clientId, msg->get_payload()); + } + } +} + +void OneSevenLiveWebsocketServer::onFail(websocketpp::connection_hdl hdl) { + std::string clientId; + + // Find client ID + { + std::lock_guard lock(clients_mutex_); + std::string hdl_str = hdl_to_string(hdl); + auto it = hdl_to_client_id_.find(hdl_str); + if (it != hdl_to_client_id_.end()) { + clientId = it->second; + + obs_log(LOG_ERROR, "[17Live WebSocket Server] Connection failed for client %s", + clientId.c_str()); + + // Remove client from connections + clients_.erase(clientId); + client_ips_.erase(clientId); + hdl_to_client_id_.erase(it); + } + } + + // Notify connection callback + if (!clientId.empty()) { + std::lock_guard lock(callback_mutex_); + if (connection_callback_) { + connection_callback_(clientId, false); + } + } +} diff --git a/src/17live/websocket/OneSevenLiveWebsocketServer.hpp b/src/17live/websocket/OneSevenLiveWebsocketServer.hpp new file mode 100644 index 0000000..4f7406e --- /dev/null +++ b/src/17live/websocket/OneSevenLiveWebsocketServer.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Include ASIO first to ensure ASIO_STANDALONE is properly defined +#include + +// Now include websocketpp - ASIO_STANDALONE should be defined +#include +#include + +typedef websocketpp::server websocketpp_server; + +class OneSevenLiveWebsocketServer { + public: + using MessageCallback = + std::function; + using ConnectionCallback = std::function; + + OneSevenLiveWebsocketServer(const std::string& host = "localhost", int port = 0); + ~OneSevenLiveWebsocketServer(); + + bool start(); + void stop(); + bool is_running() const; + int getPort() const; + + // Message broadcasting + void broadcastMessage(const std::string& message); + void sendMessageToClient(const std::string& clientId, const std::string& message); + void closeAllClients(); + + // Client management + size_t getConnectedClientsCount() const; + std::vector getConnectedClientIds() const; + + // Callback setters + void setMessageCallback(const MessageCallback& callback); + void setConnectionCallback(const ConnectionCallback& callback); + + private: + // Security-related methods + bool check_rate_limit(const std::string& client_ip); + bool validate_message_size(const std::string& message) const; + std::string generate_client_id(); + std::string get_client_ip(websocketpp::connection_hdl hdl); + std::string hdl_to_string(websocketpp::connection_hdl hdl); + + // Port management helper + int getAvailablePort() const; + + // WebSocket event handlers + void onConnection(websocketpp::connection_hdl hdl); + void onClose(websocketpp::connection_hdl hdl); + void onMessage(websocketpp::connection_hdl hdl, websocketpp_server::message_ptr msg); + void onFail(websocketpp::connection_hdl hdl); + + // Server instance + std::unique_ptr server_; + std::string host_; + int port_ = 0; + std::unique_ptr server_thread_; + std::atomic running_{false}; + + // Client management + mutable std::mutex clients_mutex_; + std::unordered_map clients_; + std::unordered_map client_ips_; + std::unordered_map hdl_to_client_id_; + + // Security-related member variables + static constexpr size_t MAX_MESSAGE_SIZE = 64 * 1024; // 64KB + static constexpr int RATE_LIMIT_MESSAGES = 50; // Maximum messages per minute + static constexpr int RATE_LIMIT_WINDOW_SECONDS = 60; + + mutable std::mutex rate_limit_mutex_; + std::unordered_map> + rate_limit_map_; + + // Callbacks + MessageCallback message_callback_; + ConnectionCallback connection_callback_; + mutable std::mutex callback_mutex_; +}; diff --git a/src/17live/websocket/WebsocketUtils.cpp b/src/17live/websocket/WebsocketUtils.cpp new file mode 100644 index 0000000..732eed6 --- /dev/null +++ b/src/17live/websocket/WebsocketUtils.cpp @@ -0,0 +1,38 @@ +#include "WebsocketUtils.hpp" + +#include "OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveWebsocketServer.hpp" +#include "WsMessage.hpp" + +void wsBroadcast(OneSevenLiveWebsocketServer* server, const WsMessage& msg) { + if (!server || !server->is_running()) + return; + server->broadcastMessage(msg.dump()); +} + +void wsBroadcast(const QString& type, const nlohmann::json& payload) { + auto& core = OneSevenLiveCoreManager::getInstance(); + auto* ws = core.getWebsocketServer(); + if (!ws || !ws->is_running()) + return; + wsBroadcast(ws, WsMessage{type.toStdString(), payload}); +} + +std::string generateWebSocketKey() { + unsigned char randomBytes[16]; + for (int i = 0; i < 16; i++) { + randomBytes[i] = static_cast(rand() & 0xFF); + } + static const char* base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string encoded; + for (int i = 0; i < 16; i += 3) { + int val = (randomBytes[i] << 16) | ((i + 1 < 16 ? randomBytes[i + 1] : 0) << 8) | + (i + 2 < 16 ? randomBytes[i + 2] : 0); + encoded.push_back(base64_chars[(val >> 18) & 0x3F]); + encoded.push_back(base64_chars[(val >> 12) & 0x3F]); + encoded.push_back(i + 1 < 16 ? base64_chars[(val >> 6) & 0x3F] : '='); + encoded.push_back(i + 2 < 16 ? base64_chars[val & 0x3F] : '='); + } + return encoded; +} \ No newline at end of file diff --git a/src/17live/websocket/WebsocketUtils.hpp b/src/17live/websocket/WebsocketUtils.hpp new file mode 100644 index 0000000..f16df3b --- /dev/null +++ b/src/17live/websocket/WebsocketUtils.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include +#include + +class OneSevenLiveWebsocketServer; +class OneSevenLiveCoreManager; +struct WsMessage; + +void wsBroadcast(OneSevenLiveWebsocketServer* server, const WsMessage& msg); +void wsBroadcast(const QString& type, const nlohmann::json& payload); +std::string generateWebSocketKey(); \ No newline at end of file diff --git a/src/17live/websocket/WsMessage.hpp b/src/17live/websocket/WsMessage.hpp new file mode 100644 index 0000000..c19f7b6 --- /dev/null +++ b/src/17live/websocket/WsMessage.hpp @@ -0,0 +1,54 @@ +#pragma once +#include +#include + +namespace ws { + static constexpr const char* TypeTransmit = "transmit"; + static constexpr const char* TypeAction = "action"; + static constexpr const char* ActionRefreshRockzone = "refresh_rockzone"; + static constexpr const char* ActionRegisterChatDock = "register_chatdock"; + static constexpr const char* EventTwitchChatConnected = "twitch_chat_connected"; + static constexpr const char* EventTwitchChatMessage = "twitch_chat_message"; + static constexpr const char* EventYouTubeChatConnected = "youtube_chat_connected"; + static constexpr const char* EventYouTubeChatMessage = "youtube_chat_message"; + static constexpr const char* EventAblyChatConnected = "ably_chat_connected"; + static constexpr const char* EventAblyChatMessage = "ably_chat_message"; +} // namespace ws + +struct WsMessage { + std::string type; + nlohmann::json payload; + + static bool parse(const std::string& s, WsMessage& out) { + try { + nlohmann::json j = nlohmann::json::parse(s); + if (j.contains("type") && j["type"].is_string()) + out.type = j["type"].get(); + if (j.contains("payload") && j["payload"].is_object()) + out.payload = j["payload"]; + else + out.payload = nlohmann::json::object(); + return true; + } catch (...) { + return false; + } + } + + std::string dump() const { + return nlohmann::json{{"type", type}, {"payload", payload}}.dump(); + } + + bool is(const std::string& t) const { + return type == t; + } + + bool is(const char* t) const { + return type == t; + } + + std::string payloadString(const std::string& key) const { + if (payload.contains(key) && payload[key].is_string()) + return payload[key].get(); + return std::string(); + } +}; diff --git a/src/17live/youtube/OneSevenLiveYouTubeAuth.cpp b/src/17live/youtube/OneSevenLiveYouTubeAuth.cpp new file mode 100644 index 0000000..a57ca5a --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeAuth.cpp @@ -0,0 +1,536 @@ +#include "OneSevenLiveYouTubeAuth.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../utility/RemoteTextThread.hpp" +#include "OneSevenLiveConfigManager.hpp" +#include "OneSevenLiveCoreManager.hpp" +#include "plugin-support.h" + +using Json = nlohmann::json; + +const QString OneSevenLiveYouTubeAuth::YT_AUTH_URL_TEMPLATE = + "https://accounts.google.com/o/oauth2/v2/" + "auth?scope=%1&response_type=code&state=%2&redirect_uri=%3&client_id=%4"; +const QString OneSevenLiveYouTubeAuth::YT_SCOPE = + "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.readonly " + "https://www.googleapis.com/auth/youtube.force-ssl"; +const QString OneSevenLiveYouTubeAuth::YT_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const QString OneSevenLiveYouTubeAuth::PLATFORM = "YouTube"; + +OneSevenLiveYouTubeAuth::OneSevenLiveYouTubeAuth(QObject* parent) : QObject(parent) {} + +OneSevenLiveYouTubeAuth::~OneSevenLiveYouTubeAuth() {} + +QString OneSevenLiveYouTubeAuth::getAuthUrl(const QString& redirectUri) { + m_redirectUri = redirectUri; + const QByteArray scopeEnc = QUrl::toPercentEncoding(getScope()); + const QByteArray stateEnc = QUrl::toPercentEncoding(getState()); + const QByteArray redirectEnc = QUrl::toPercentEncoding(redirectUri); + const QByteArray clientIdEnc = QUrl::toPercentEncoding(getClientId()); + return YT_AUTH_URL_TEMPLATE.arg(QString::fromUtf8(scopeEnc), QString::fromUtf8(stateEnc), + QString::fromUtf8(redirectEnc), QString::fromUtf8(clientIdEnc)); +} + +QString OneSevenLiveYouTubeAuth::getState() { + if (m_state.isEmpty()) { + QByteArray bytes; + bytes.resize(16); // 128-bit random + for (int i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(QRandomGenerator::global()->bounded(256)); + } + m_state = QString::fromLatin1(bytes.toHex()); + } + return m_state; +} + +bool OneSevenLiveYouTubeAuth::validateState(const QString& state) const { + return !m_state.isEmpty() && state == m_state; +} + +bool OneSevenLiveYouTubeAuth::hasValidToken() const { + return !m_accessToken.isEmpty(); +} + +void OneSevenLiveYouTubeAuth::setAccessToken(const QString& token) { + m_accessToken = token; +} + +void OneSevenLiveYouTubeAuth::clearToken() { + m_accessToken.clear(); +} + +bool OneSevenLiveYouTubeAuth::handleAuthorizationCallbackUrl(const QString& callbackUrl) { + QUrl url(callbackUrl); + if (!url.isValid()) { + obs_log(LOG_WARNING, "YouTube callback URL invalid: %s", callbackUrl.toUtf8().constData()); + return false; + } + + const QUrlQuery query(url.query()); + const QString error = query.queryItemValue("error"); + const QString errorDescription = query.queryItemValue("error_description"); + if (!error.isEmpty()) { + const QString desc = errorDescription.isEmpty() ? error : errorDescription; + obs_log(LOG_WARNING, "YouTube authorization error: %s - %s", error.toUtf8().constData(), + desc.toUtf8().constData()); + QMessageBox::warning(nullptr, obs_module_text("Live.Common.Notice"), + QString("YouTube authorization failed: %1").arg(desc)); + emit authorizationFailed(desc); + return false; + } + + const QString code = query.queryItemValue("code"); + const QString state = query.queryItemValue("state"); + const QString scope = query.queryItemValue("scope"); + + if (code.isEmpty()) { + obs_log(LOG_WARNING, "YouTube authorization code not found in callback query"); + emit authorizationFailed("Authorization code missing in callback"); + return false; + } + + if (!state.isEmpty() && !validateState(state)) { + obs_log(LOG_WARNING, "YouTube callback state mismatch: expected=%s got=%s", + m_state.toUtf8().constData(), state.toUtf8().constData()); + } + + // Exchange code for tokens + const QString tokenUrl = YT_TOKEN_URL; + + const QByteArray codeEnc = QUrl::toPercentEncoding(code); + const QByteArray clientIdEnc = QUrl::toPercentEncoding(getClientId()); + const QByteArray clientSecretEnc = QUrl::toPercentEncoding(getClientSecret()); + const QByteArray redirectEnc = QUrl::toPercentEncoding(m_redirectUri); + + std::string postData = + QString( + "code=%1&client_id=%2&client_secret=%3&redirect_uri=%4&grant_type=authorization_code") + .arg(QString::fromUtf8(codeEnc), QString::fromUtf8(clientIdEnc), + QString::fromUtf8(clientSecretEnc), QString::fromUtf8(redirectEnc)) + .toStdString(); + + std::string responseBody; + std::string httpError; + long httpStatusCode = 0; + bool ok = GetRemoteFile(tokenUrl.toUtf8().constData(), responseBody, httpError, &httpStatusCode, + "application/x-www-form-urlencoded", "POST", postData.c_str(), + std::vector(), nullptr, /*timeout*/ 0, + /*fail_on_error*/ true, static_cast(postData.size())); + + if (!ok || httpStatusCode < 200 || httpStatusCode >= 300) { + obs_log(LOG_ERROR, "YouTube token exchange failed (HTTP %ld): %s", httpStatusCode, + httpError.c_str()); + emit authorizationFailed(QString::fromUtf8(httpError.c_str())); + return false; + } + + // Parse JSON response + QString accessToken; + int expiresIn = 0; + QString tokenType; + QString refreshToken; + int refreshTokenExpiresIn = 0; + + try { + Json json = Json::parse(responseBody); + if (json.contains("access_token") && json["access_token"].is_string()) { + accessToken = QString::fromStdString(json["access_token"].get()); + } + if (json.contains("expires_in") && json["expires_in"].is_number_integer()) { + expiresIn = json["expires_in"].get(); + } + if (json.contains("token_type") && json["token_type"].is_string()) { + tokenType = QString::fromStdString(json["token_type"].get()); + } + if (json.contains("refresh_token") && json["refresh_token"].is_string()) { + refreshToken = QString::fromStdString(json["refresh_token"].get()); + } + if (json.contains("refresh_token_expires_in") && + json["refresh_token_expires_in"].is_number_integer()) { + refreshTokenExpiresIn = json["refresh_token_expires_in"].get(); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "Failed to parse YouTube token JSON: %s", e.what()); + emit authorizationFailed("Failed to parse token response"); + return false; + } + + if (accessToken.isEmpty()) { + obs_log(LOG_ERROR, "YouTube token exchange did not return access_token"); + emit authorizationFailed("Token exchange missing access_token"); + return false; + } + + // Persist token, fetched time, and expires_in + OneSevenLiveConfigManager* cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (!cfg || !cfg->initialize()) { + obs_log(LOG_ERROR, "ConfigManager not initialized; cannot save YouTube token"); + emit authorizationFailed("Configuration manager not initialized"); + return false; + } + + const qint64 nowEpoch = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + if (!cfg->setYouTubeAccessToken(accessToken, expiresIn, nowEpoch)) { + obs_log(LOG_ERROR, "Failed to save YouTube access token"); + emit authorizationFailed("Failed to save YouTube access token"); + return false; + } + if (!cfg->setYouTubeRefreshToken(refreshToken, refreshTokenExpiresIn, nowEpoch)) { + obs_log(LOG_ERROR, "Failed to save YouTube refresh token"); + emit authorizationFailed("Failed to save YouTube refresh token"); + return false; + } + + // Update local state and notify + setAccessToken(accessToken); + m_refreshToken = refreshToken; + m_callbackScope = scope; + obs_log(LOG_INFO, "YouTube token exchange success: scope=%s token_type=%s expires_in=%d", + m_callbackScope.toUtf8().constData(), tokenType.toUtf8().constData(), expiresIn); + + // Schedule auto refresh one minute before expiry + scheduleAutoRefresh(expiresIn, nowEpoch, refreshTokenExpiresIn, nowEpoch); + + emit authorizationCompleted(m_accessToken); + return true; +} + +QString OneSevenLiveYouTubeAuth::getClientId() const { + return QString(YOUTUBE_API_CLIENT_ID); +} + +QString OneSevenLiveYouTubeAuth::getClientSecret() const { + return QString(YOUTUBE_API_CLIENT_SECRET); +} + +QString OneSevenLiveYouTubeAuth::getScope() const { + return YT_SCOPE; +} + +bool OneSevenLiveYouTubeAuth::refreshAccessToken() { + // Acquire refresh_token from memory or config + QString rt = m_refreshToken; + OneSevenLiveConfigManager* cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (rt.isEmpty()) { + if (!cfg || !cfg->initialize()) { + obs_log(LOG_ERROR, "ConfigManager not initialized; cannot refresh YouTube token"); + return false; + } + QString cfgRt; + int rtExpiresIn = 0; + qint64 rtFetched = 0; + if (!cfg->getYouTubeRefreshToken(cfgRt, rtExpiresIn, rtFetched)) { + obs_log(LOG_ERROR, "No YouTube refresh token available in config"); + return false; + } + rt = cfgRt; + } + + if (rt.isEmpty()) { + obs_log(LOG_ERROR, "YouTube refresh token is empty; cannot refresh"); + return false; + } + + // Build POST body per Google OAuth refresh flow + const QByteArray clientIdEnc = QUrl::toPercentEncoding(getClientId()); + const QByteArray clientSecretEnc = QUrl::toPercentEncoding(getClientSecret()); + const QByteArray refreshEnc = QUrl::toPercentEncoding(rt); + std::string postData = + QString("client_id=%1&client_secret=%2&refresh_token=%3&grant_type=refresh_token") + .arg(QString::fromUtf8(clientIdEnc), QString::fromUtf8(clientSecretEnc), + QString::fromUtf8(refreshEnc)) + .toStdString(); + + std::string responseBody; + std::string error; + long httpStatusCode = 0; + bool ok = GetRemoteFile(YT_TOKEN_URL.toUtf8().constData(), responseBody, error, &httpStatusCode, + "application/x-www-form-urlencoded", "POST", postData.c_str(), + std::vector(), nullptr, /*timeout*/ 0, + /*fail_on_error*/ true, static_cast(postData.size())); + + if (!ok || httpStatusCode < 200 || httpStatusCode >= 300) { + obs_log(LOG_ERROR, "YouTube token refresh failed (HTTP %ld): %s", httpStatusCode, + error.c_str()); + return false; + } + + // Parse refreshed token response + QString newAccessToken; + int expiresIn = 0; + QString tokenType; + QString scope; + try { + Json json = Json::parse(responseBody); + if (json.contains("access_token") && json["access_token"].is_string()) { + newAccessToken = QString::fromStdString(json["access_token"].get()); + } + if (json.contains("expires_in") && json["expires_in"].is_number_integer()) { + expiresIn = json["expires_in"].get(); + } + if (json.contains("token_type") && json["token_type"].is_string()) { + tokenType = QString::fromStdString(json["token_type"].get()); + } + if (json.contains("scope") && json["scope"].is_string()) { + scope = QString::fromStdString(json["scope"].get()); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "Failed to parse YouTube refresh JSON: %s", e.what()); + return false; + } + + if (newAccessToken.isEmpty()) { + obs_log(LOG_ERROR, "YouTube token refresh did not return access_token"); + return false; + } + + // Persist refreshed access_token with expires_in and fetched time + if (!cfg || !cfg->initialize()) { + cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (!cfg || !cfg->initialize()) { + obs_log(LOG_ERROR, "ConfigManager not initialized; cannot persist refreshed token"); + return false; + } + } + + const qint64 nowEpoch = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + if (!cfg->setYouTubeAccessToken(newAccessToken, expiresIn, nowEpoch)) { + obs_log(LOG_ERROR, "Failed to save refreshed YouTube access token"); + return false; + } + + // Update in-memory token + setAccessToken(newAccessToken); + m_callbackScope = scope; + { + QString tok = newAccessToken; + QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + obs_log(LOG_INFO, "YouTube access token updated in memory token(masked)=%s", + masked.toUtf8().constData()); + } + obs_log(LOG_INFO, "YouTube token refreshed: token_type=%s expires_in=%d", + tokenType.toUtf8().constData(), expiresIn); + // Notify listeners using existing signal for simplicity + emit authorizationCompleted(m_accessToken); + return true; +} + +void OneSevenLiveYouTubeAuth::refreshAccessTokenAsync() { + QString rt = m_refreshToken; + OneSevenLiveConfigManager* cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (rt.isEmpty()) { + if (!cfg || !cfg->initialize()) { + obs_log(LOG_ERROR, "ConfigManager not initialized; cannot refresh YouTube token"); + emit authorizationFailed("ConfigManager not initialized"); + return; + } + QString cfgRt; + int rtExpiresIn = 0; + qint64 rtFetched = 0; + if (!cfg->getYouTubeRefreshToken(cfgRt, rtExpiresIn, rtFetched)) { + obs_log(LOG_ERROR, "No YouTube refresh token available in config"); + emit authorizationFailed("YouTube refresh token missing"); + return; + } + rt = cfgRt; + } + + if (rt.isEmpty()) { + obs_log(LOG_ERROR, "YouTube refresh token is empty; cannot refresh"); + emit authorizationFailed("YouTube refresh token empty"); + return; + } + + const QByteArray clientIdEnc = QUrl::toPercentEncoding(getClientId()); + const QByteArray clientSecretEnc = QUrl::toPercentEncoding(getClientSecret()); + const QByteArray refreshEnc = QUrl::toPercentEncoding(rt); + std::string postData = + QString("client_id=%1&client_secret=%2&refresh_token=%3&grant_type=refresh_token") + .arg(QString::fromUtf8(clientIdEnc), QString::fromUtf8(clientSecretEnc), + QString::fromUtf8(refreshEnc)) + .toStdString(); + + std::atomic* cancelFlag = OneSevenLiveCoreManager::getInstance().getCancelFlag(); + auto* thread = + new RemoteTextThread(YT_TOKEN_URL.toUtf8().constData(), "application/x-www-form-urlencoded", + postData, 0, false, cancelFlag); + QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater); + QObject::connect( + thread, &RemoteTextThread::Result, this, + [this, thread](const QString& text, const QString& error) { + if (!error.isEmpty()) { + obs_log(LOG_ERROR, "YouTube token refresh failed: %s", error.toUtf8().constData()); + emit authorizationFailed(error); + return; + } + + QString newAccessToken; + int expiresIn = 0; + QString tokenType; + QString scope; + try { + Json json = Json::parse(text.toUtf8().constData()); + if (json.contains("access_token") && json["access_token"].is_string()) { + newAccessToken = + QString::fromStdString(json["access_token"].get()); + } + if (json.contains("expires_in") && json["expires_in"].is_number_integer()) { + expiresIn = json["expires_in"].get(); + } + if (json.contains("token_type") && json["token_type"].is_string()) { + tokenType = QString::fromStdString(json["token_type"].get()); + } + if (json.contains("scope") && json["scope"].is_string()) { + scope = QString::fromStdString(json["scope"].get()); + } + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "Failed to parse YouTube refresh JSON: %s", e.what()); + emit authorizationFailed("Failed to parse refresh response"); + return; + } + + if (newAccessToken.isEmpty()) { + obs_log(LOG_ERROR, "YouTube token refresh did not return access_token"); + emit authorizationFailed("Refresh missing access_token"); + return; + } + + OneSevenLiveConfigManager* cfgLocal = + OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (!cfgLocal || !cfgLocal->initialize()) { + cfgLocal = OneSevenLiveCoreManager::getInstance().getConfigManager(); + if (!cfgLocal || !cfgLocal->initialize()) { + obs_log(LOG_ERROR, + "ConfigManager not initialized; cannot persist refreshed token"); + emit authorizationFailed("Configuration manager not initialized"); + return; + } + } + + const qint64 nowEpoch = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + if (!cfgLocal->setYouTubeAccessToken(newAccessToken, expiresIn, nowEpoch)) { + obs_log(LOG_ERROR, "Failed to save refreshed YouTube access token"); + emit authorizationFailed("Failed to save refreshed YouTube access token"); + return; + } + + setAccessToken(newAccessToken); + m_callbackScope = scope; + { + QString tok = newAccessToken; + QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + obs_log(LOG_INFO, "YouTube access token updated in memory token(masked)=%s", + masked.toUtf8().constData()); + } + obs_log(LOG_INFO, "YouTube token refreshed: token_type=%s expires_in=%d", + tokenType.toUtf8().constData(), expiresIn); + emit authorizationCompleted(m_accessToken); + }); + thread->start(); +} + +void OneSevenLiveYouTubeAuth::scheduleAutoRefresh(int accessExpiresInSec, + qint64 accessFetchedAtEpochSec, + int refreshExpiresInSec, + qint64 refreshFetchedAtEpochSec) { + OneSevenLiveConfigManager* cfg = OneSevenLiveCoreManager::getInstance().getConfigManager(); + obs_log(LOG_INFO, + "YouTube scheduleAutoRefresh inputs: access_expires_in=%d access_fetched_at=%lld " + "refresh_expires_in=%d refresh_fetched_at=%lld", + accessExpiresInSec, (long long) accessFetchedAtEpochSec, refreshExpiresInSec, + (long long) refreshFetchedAtEpochSec); + + // Load refresh token from config if missing + if (m_refreshToken.isEmpty() && cfg && cfg->initialize()) { + QString rt; + int rtExp{0}; + qint64 rtFetched{0}; + if (cfg->getYouTubeRefreshToken(rt, rtExp, rtFetched)) { + m_refreshToken = rt; + if (refreshExpiresInSec <= 0) + refreshExpiresInSec = rtExp; + if (refreshFetchedAtEpochSec <= 0) + refreshFetchedAtEpochSec = rtFetched; + QString tok = m_refreshToken; + QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + obs_log(LOG_INFO, + "YouTube refresh token loaded from config present=%s exp_in=%d fetched_at=%lld " + "token(masked)=%s", + m_refreshToken.isEmpty() ? "false" : "true", refreshExpiresInSec, + (long long) refreshFetchedAtEpochSec, masked.toUtf8().constData()); + } + } + + const qint64 nowEpoch = QDateTime::currentDateTimeUtc().toSecsSinceEpoch(); + const qint64 accessExpiresAt = accessFetchedAtEpochSec + accessExpiresInSec; + const qint64 refreshExpiresAt = refreshFetchedAtEpochSec + refreshExpiresInSec; + obs_log(LOG_INFO, + "YouTube token timing: now=%lld access_expires_at=%lld refresh_expires_at=%lld", + (long long) nowEpoch, (long long) accessExpiresAt, (long long) refreshExpiresAt); + + // Handle already-expired access token on startup + if (accessExpiresInSec > 0 && nowEpoch >= accessExpiresAt) { + if (!m_refreshToken.isEmpty()) { + if (refreshExpiresInSec <= 0 || nowEpoch < refreshExpiresAt) { + obs_log(LOG_INFO, "YouTube access token expired; scheduling async refresh"); + QTimer::singleShot(0, this, &OneSevenLiveYouTubeAuth::refreshAccessTokenAsync); + } else if (cfg && cfg->initialize()) { + obs_log(LOG_INFO, "YouTube refresh token expired; clearing stored tokens"); + cfg->clearYouTubeAccessToken(); + cfg->clearYouTubeRefreshToken(); + } + } else if (cfg && cfg->initialize()) { + obs_log(LOG_INFO, "YouTube refresh token missing; clearing stored tokens"); + cfg->clearYouTubeAccessToken(); + cfg->clearYouTubeRefreshToken(); + } + return; + } + + // Schedule one minute before expiry + if (accessExpiresInSec > 0 && accessFetchedAtEpochSec > 0) { + const qint64 refreshAt = accessExpiresAt - 60; // 1 minute before + qint64 delaySec = refreshAt - nowEpoch; + if (delaySec < 0) + delaySec = 0; + obs_log(LOG_INFO, "YouTube auto-refresh plan: refresh_at=%lld delay_sec=%lld", + (long long) refreshAt, (long long) delaySec); + + if (!m_refreshTimer) { + m_refreshTimer = new QTimer(this); + m_refreshTimer->setSingleShot(true); + connect(m_refreshTimer, &QTimer::timeout, this, + &OneSevenLiveYouTubeAuth::onRefreshTimerTimeout); + obs_log(LOG_INFO, "YouTube auto-refresh timer created"); + } + + if (!m_refreshToken.isEmpty()) { + obs_log(LOG_INFO, "Scheduling YouTube access token refresh in %lld sec", + (long long) delaySec); + m_refreshTimer->start(static_cast(delaySec * 1000)); + } else { + obs_log(LOG_INFO, "No YouTube refresh token; auto-refresh not scheduled"); + } + } +} + +void OneSevenLiveYouTubeAuth::stopAutoRefresh() { + if (m_refreshTimer && m_refreshTimer->isActive()) { + m_refreshTimer->stop(); + } +} + +void OneSevenLiveYouTubeAuth::onRefreshTimerTimeout() { + if (!refreshAccessToken()) { + obs_log(LOG_ERROR, "Scheduled YouTube token refresh failed"); + } +} diff --git a/src/17live/youtube/OneSevenLiveYouTubeAuth.hpp b/src/17live/youtube/OneSevenLiveYouTubeAuth.hpp new file mode 100644 index 0000000..17b2ebf --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeAuth.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include + +class QTimer; + +/** + * YouTube authorization handler using implicit grant flow + * Encapsulates building auth URL and handling the callback to persist tokens. + */ +class OneSevenLiveYouTubeAuth : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveYouTubeAuth(QObject* parent = nullptr); + ~OneSevenLiveYouTubeAuth(); + + // Build authorization URL for the given redirect URI + QString getAuthUrl(const QString& redirectUri); + + // CSRF state helpers + QString getState(); + bool validateState(const QString& state) const; + + // Callback handler: parse query and persist access token + // Returns true on success; false on error or unexpected format + bool handleAuthorizationCallbackUrl(const QString& callbackUrl); + + // Refresh the access token using stored refresh_token + // Returns true on success; persists new token and updates in-memory state + bool refreshAccessToken(); + void refreshAccessTokenAsync(); + + // Schedule auto-refresh 1 minute before access token expiry + // If already expired on startup, refresh immediately if refresh_token is valid, + // otherwise clear tokens from config + void scheduleAutoRefresh(int accessExpiresInSec, qint64 accessFetchedAtEpochSec, + int refreshExpiresInSec, qint64 refreshFetchedAtEpochSec); + void stopAutoRefresh(); + + QString getRedirectUri() const { + return m_redirectUri; + } + + // Token state + bool hasValidToken() const; + + QString getAccessToken() const { + return m_accessToken; + } + + void setAccessToken(const QString& token); + void clearToken(); + + signals: + void authorizationCompleted(const QString& accessToken); + void authorizationFailed(const QString& error); + + private: + QString getClientId() const; + QString getClientSecret() const; + QString getScope() const; + + // Internal state + QString m_state; + QString m_accessToken; + QString m_callbackScope; + QString m_redirectUri; + QString m_refreshToken; + QTimer* m_refreshTimer{nullptr}; + + public: + // Constants + static const QString YT_AUTH_URL_TEMPLATE; + static const QString YT_SCOPE; + static const QString YT_TOKEN_URL; + static const QString PLATFORM; + + private slots: + void onRefreshTimerTimeout(); +}; diff --git a/src/17live/youtube/OneSevenLiveYouTubeChatClient.cpp b/src/17live/youtube/OneSevenLiveYouTubeChatClient.cpp new file mode 100644 index 0000000..06e39b8 --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeChatClient.cpp @@ -0,0 +1,665 @@ +#include "OneSevenLiveYouTubeChatClient.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" +#include "OneSevenLiveYouTubeClient.hpp" +#include "api/OneSevenLiveModels.hpp" +#include "multi-rtmp/OneSevenLiveMultiRtmpManager.hpp" +#include "plugin-support.h" +#include "utility/RemoteTextThread.hpp" +#include "websocket/OneSevenLiveWebsocketServer.hpp" +#include "websocket/WebsocketUtils.hpp" +#include "websocket/WsMessage.hpp" + +const QString OneSevenLiveYouTubeChatClient::YOUTUBE_API_BASE_URL = + "https://www.googleapis.com/youtube/v3"; +const QString OneSevenLiveYouTubeChatClient::YOUTUBE_API_VERSION = "v3"; +const int OneSevenLiveYouTubeChatClient::DEFAULT_POLLING_INTERVAL = 5000; // 5 seconds +const int OneSevenLiveYouTubeChatClient::MIN_POLLING_INTERVAL = 10000; +const int OneSevenLiveYouTubeChatClient::MAX_EXPONENTIAL_BACKOFF_DELAY = 32000; // 32 seconds max +const int OneSevenLiveYouTubeChatClient::STATUS_BROADCAST_INTERVAL = 10; +const int OneSevenLiveYouTubeChatClient::MAX_QUICK_RETRIES = 5; +const int OneSevenLiveYouTubeChatClient::LONG_RETRY_DELAY = 600; +const int OneSevenLiveYouTubeChatClient::MAX_NO_MESSAGE_QUICK_POLLS = 5; + +// Helper: convert YouTubeChatMessage to JSON for websocket payload +static nlohmann::json toJson(const YouTubeChatMessage& msg) { + nlohmann::json j; + j["kind"] = msg.kind.toStdString(); + j["etag"] = msg.etag.toStdString(); + j["id"] = msg.id.toStdString(); + + nlohmann::json snippet; + snippet["type"] = msg.snippet.type.toStdString(); + snippet["liveChatId"] = msg.snippet.liveChatId.toStdString(); + snippet["authorChannelId"] = msg.snippet.authorChannelId.toStdString(); + snippet["publishedAt"] = msg.snippet.publishedAt.toStdString(); + snippet["displayMessage"] = msg.snippet.displayMessage.toStdString(); + snippet["textMessageDetails"] = msg.snippet.textMessageDetails.toStdString(); + snippet["messageId"] = msg.snippet.messageId.toStdString(); + j["snippet"] = snippet; + + nlohmann::json author; + author["channelId"] = msg.authorDetails.channelId.toStdString(); + author["displayName"] = msg.authorDetails.displayName.toStdString(); + author["profileImageUrl"] = msg.authorDetails.profileImageUrl.toStdString(); + author["isVerified"] = msg.authorDetails.isVerified; + author["isChatOwner"] = msg.authorDetails.isChatOwner; + author["isChatSponsor"] = msg.authorDetails.isChatSponsor; + author["isChatModerator"] = msg.authorDetails.isChatModerator; + j["authorDetails"] = author; + + return j; +} + +OneSevenLiveYouTubeChatClient::OneSevenLiveYouTubeChatClient(QObject* parent) + : QObject(parent), + m_timeoutMs(30000) // 30 seconds default timeout + , + m_maxRetries(3), + m_retryDelayMs(1000) // 1 second base delay + , + m_currentRetryCount(0), + m_reconnectAttempts(0), + m_noMessageStreak(0), + m_hasValidAuth(false), + m_isPolling(false), + m_isRateLimited(false), + m_currentPollingInterval(DEFAULT_POLLING_INTERVAL), + m_exponentialBackoffDelay(m_retryDelayMs), + m_pollingTimer(new QTimer(this)), + m_statusTimer(new QTimer(this)), + m_reconnectTimer(new QTimer(this)) { + connect(m_pollingTimer, &QTimer::timeout, this, + &OneSevenLiveYouTubeChatClient::onPollingTimeout); + m_pollingTimer->setSingleShot(true); // Single shot timer for controlled polling + connect(m_statusTimer, &QTimer::timeout, this, &OneSevenLiveYouTubeChatClient::onStatusTimer); + m_statusTimer->setInterval(STATUS_BROADCAST_INTERVAL * 1000); + m_reconnectTimer->setSingleShot(true); + connect(m_reconnectTimer, &QTimer::timeout, this, &OneSevenLiveYouTubeChatClient::doReconnect); +} + +OneSevenLiveYouTubeChatClient::~OneSevenLiveYouTubeChatClient() { + stopChatPolling(); +} + +void OneSevenLiveYouTubeChatClient::setAccessToken(const QString& accessToken) { + m_accessToken = accessToken; + m_hasValidAuth = !accessToken.isEmpty(); + obs_log(LOG_INFO, "YouTube chat access token set, valid: %s", + m_hasValidAuth ? "true" : "false"); +} + +bool OneSevenLiveYouTubeChatClient::hasValidAuth() const { + return m_hasValidAuth; +} + +void OneSevenLiveYouTubeChatClient::startChatPolling(const QString& liveChatId) { + if (liveChatId.isEmpty()) { + emit errorOccurred("Live Chat ID cannot be empty", "startChatPolling"); + return; + } + + if (!m_hasValidAuth && m_apiKey.isEmpty()) { + emit errorOccurred("No valid authentication (access token or API key)", "startChatPolling"); + return; + } + + bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("YouTube"); + if (!isLive) { + obs_log(LOG_INFO, "YouTube stream not live; skipping chat polling start"); + return; + } + + if (m_isPolling) { + obs_log(LOG_INFO, "Chat polling already running, stopping first"); + stopChatPolling(); + } + + m_liveChatId = liveChatId; + m_nextPageToken.clear(); + m_currentRetryCount = 0; + m_exponentialBackoffDelay = m_retryDelayMs; + m_isRateLimited = false; + + obs_log(LOG_INFO, "Starting YouTube chat polling for liveChatId: %s", + liveChatId.toUtf8().constData()); + + m_isPolling = true; + m_reconnectAttempts = 0; + if (m_reconnectTimer->isActive()) + m_reconnectTimer->stop(); + emit pollingStarted(liveChatId); + + if (!m_statusTimer->isActive()) + m_statusTimer->start(); + obs_log(LOG_INFO, "YouTube chat connected"); + + // Start first request immediately + fetchChatMessages(); +} + +void OneSevenLiveYouTubeChatClient::stopChatPolling() { + if (!m_isPolling) { + return; + } + + obs_log(LOG_INFO, "Stopping YouTube chat polling"); + + m_isPolling = false; + m_pollingTimer->stop(); + + m_liveChatId.clear(); + m_nextPageToken.clear(); + m_currentRetryCount = 0; + m_exponentialBackoffDelay = m_retryDelayMs; + m_isRateLimited = false; + m_noMessageStreak = 0; + + emit pollingStopped(); + if (m_statusTimer->isActive()) + m_statusTimer->stop(); + if (m_reconnectTimer->isActive()) + m_reconnectTimer->stop(); +} + +bool OneSevenLiveYouTubeChatClient::isPolling() const { + return m_isPolling; +} + +void OneSevenLiveYouTubeChatClient::setApiKey(const QString& apiKey) { + m_apiKey = apiKey; + obs_log(LOG_INFO, "YouTube chat API key set"); +} + +void OneSevenLiveYouTubeChatClient::setTimeout(int timeoutMs) { + m_timeoutMs = timeoutMs; + obs_log(LOG_INFO, "API timeout set to %d ms", timeoutMs); +} + +void OneSevenLiveYouTubeChatClient::setMaxRetries(int maxRetries) { + m_maxRetries = maxRetries; + obs_log(LOG_INFO, "Max retries set to %d", maxRetries); +} + +void OneSevenLiveYouTubeChatClient::setRetryDelay(int baseDelayMs) { + m_retryDelayMs = baseDelayMs; + m_exponentialBackoffDelay = baseDelayMs; + obs_log(LOG_INFO, "Retry delay set to %d ms", baseDelayMs); +} + +void OneSevenLiveYouTubeChatClient::setApiClient(OneSevenLiveYouTubeClient* apiClient) { + m_apiClient = apiClient; + if (!m_apiClient) + return; + connect(m_apiClient, &OneSevenLiveYouTubeClient::myLiveBroadcastsReceived, this, + &OneSevenLiveYouTubeChatClient::onBroadcastsReceived); +} + +void OneSevenLiveYouTubeChatClient::startDiscovery() { + if (!m_apiClient) + return; + if (!m_discoverTimer) { + m_discoverTimer = new QTimer(this); + m_discoverTimer->setInterval(60000); + connect(m_discoverTimer, &QTimer::timeout, this, [this]() { + if (m_apiClient && m_apiClient->hasValidAuth()) + m_apiClient->getMyLiveBroadcasts(); + }); + } + if (m_apiClient->hasValidAuth()) { + if (!m_discoverTimer->isActive()) + m_discoverTimer->start(); + m_apiClient->getMyLiveBroadcasts(); + } else { + if (m_discoverTimer->isActive()) + m_discoverTimer->stop(); + } +} + +void OneSevenLiveYouTubeChatClient::stopDiscovery() { + if (m_discoverTimer && m_discoverTimer->isActive()) + m_discoverTimer->stop(); +} + +void OneSevenLiveYouTubeChatClient::fetchChatMessages() { + if (!m_isPolling || m_liveChatId.isEmpty()) { + return; + } + + if (!m_hasValidAuth && m_apiKey.isEmpty()) { + return; + } + + bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("YouTube"); + if (!isLive) { + return; + } + + QString endpoint = buildChatMessagesUrl(m_liveChatId, m_nextPageToken); + // obs_log(LOG_INFO, "YouTube chat fetch: liveChatId=%s pageToken=%s", + // m_liveChatId.toUtf8().constData(), m_nextPageToken.toUtf8().constData()); + makeChatRequest(endpoint); +} + +void OneSevenLiveYouTubeChatClient::scheduleNextPoll(int intervalMs) { + if (!m_isPolling) { + return; + } + + intervalMs = qMax(intervalMs, MIN_POLLING_INTERVAL); + m_currentPollingInterval = intervalMs; + + obs_log(LOG_DEBUG, "Scheduling next poll in %d ms", intervalMs); + m_pollingTimer->start(intervalMs); +} + +void OneSevenLiveYouTubeChatClient::handleRateLimit(int retryAfterMs) { + m_isRateLimited = true; + int actualDelay = qMax(retryAfterMs, m_exponentialBackoffDelay); + + obs_log(LOG_WARNING, "Rate limit hit, scheduling retry in %d ms", actualDelay); + emit rateLimitHit(actualDelay); + + // Exponential backoff for next time + m_exponentialBackoffDelay = qMin(m_exponentialBackoffDelay * 2, MAX_EXPONENTIAL_BACKOFF_DELAY); + + scheduleNextPoll(actualDelay); +} + +void OneSevenLiveYouTubeChatClient::handleApiError(const QString& error, const QString& operation, + int httpStatus) { + QString detailedError; + + switch (httpStatus) { + case 401: + detailedError = "Authentication failed - invalid or expired token"; + m_hasValidAuth = false; + if (m_apiKey.isEmpty()) { + stopChatPolling(); + return; + } + break; + case 403: + detailedError = "Access forbidden - insufficient permissions or quota exceeded"; + if (error.contains("quotaExceeded") || error.contains("rateLimitExceeded")) { + handleRateLimit(60000); // 1 minute for quota issues + return; + } + break; + case 404: + detailedError = "Live chat not found"; + break; + case 429: + detailedError = "Rate limit exceeded"; + handleRateLimit(30000); // 30 seconds for rate limits + return; + default: + detailedError = error; + break; + } + + emit errorOccurred(detailedError, operation); + + // For non-rate-limit errors, continue with normal polling interval + if (m_isPolling && httpStatus != 429 && !error.contains("quotaExceeded")) { + scheduleNextPoll(m_currentPollingInterval); + } +} + +QString OneSevenLiveYouTubeChatClient::buildChatMessagesUrl(const QString& liveChatId, + const QString& pageToken) const { + QString url = YOUTUBE_API_BASE_URL + "/liveChat/messages"; + + QUrlQuery query; + query.addQueryItem("liveChatId", liveChatId); + query.addQueryItem("part", "snippet,authorDetails"); + + if (!pageToken.isEmpty()) { + query.addQueryItem("pageToken", pageToken); + } + + if (!m_apiKey.isEmpty()) { + query.addQueryItem("key", m_apiKey); + } + + return url + "?" + query.toString(); +} + +void OneSevenLiveYouTubeChatClient::makeChatRequest(const QString& endpoint) { + obs_log(LOG_DEBUG, "YouTube Chat API Request: %s", endpoint.toUtf8().constData()); + m_lastEndpoint = endpoint; + if (m_hasValidAuth) { + const QString tok = m_accessToken; + const QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + obs_log(LOG_DEBUG, "YouTube Chat token(masked)=%s auth_mode=Bearer", + masked.toUtf8().constData()); + } + + // Build headers + std::vector headers; + headers.push_back(std::string("Accept: application/json")); + if (m_hasValidAuth && !m_accessToken.isEmpty()) { + std::string bearer = std::string("Authorization: Bearer ") + m_accessToken.toStdString(); + headers.push_back(bearer); + } + + std::atomic* cancelFlag = OneSevenLiveCoreManager::getInstance().getCancelFlag(); + RemoteTextThread* thread = + new RemoteTextThread(endpoint.toStdString(), std::move(headers), "application/json", + std::string(), m_timeoutMs / 1000, false, cancelFlag); + + m_currentOperation = "getChatMessages"; + + connect(thread, &RemoteTextThread::Result, this, + &OneSevenLiveYouTubeChatClient::onChatRequestFinished, Qt::QueuedConnection); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +void OneSevenLiveYouTubeChatClient::onBroadcastsReceived( + const YouTubeLiveBroadcastListResponse& resp) { + QString discovered; + for (const auto& b : resp.items) { + if (!b.snippet.liveChatId.isEmpty()) { + discovered = b.snippet.liveChatId; + break; + } + } + if (discovered.isEmpty()) { + if (!isPolling()) { + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + } + return; + } + if (!m_liveChatId.isEmpty() && discovered == m_liveChatId) { + if (!isPolling()) { + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + } + return; + } + startChatPolling(discovered); +} + +void OneSevenLiveYouTubeChatClient::onChatRequestFinished(const QString& response, + const QString& error) { + if (!m_isPolling) { + return; // Ignore responses if polling was stopped + } + + if (!error.isEmpty()) { + obs_log(LOG_WARNING, "YouTube Chat API Error: %s", error.toUtf8().constData()); + + // Extract HTTP status code from error if possible + int httpStatus = -1; + QRegularExpression statusRegex(R"(HTTP (\d{3}))"); + QRegularExpressionMatch match = statusRegex.match(error); + if (match.hasMatch()) { + httpStatus = match.captured(1).toInt(); + } + + obs_log(LOG_WARNING, "YouTube Chat API Error context: op=%s endpoint=%s", + m_currentOperation.toUtf8().constData(), m_lastEndpoint.toUtf8().constData()); + if (!response.isEmpty()) { + obs_log(LOG_WARNING, "YouTube Chat API Error response: %s", + response.toUtf8().constData()); + try { + auto j = nlohmann::json::parse(response.toStdString()); + auto ej = j.contains("error") ? j["error"] : nlohmann::json{}; + std::string emsg = ej.value("message", std::string()); + std::string estatus = ej.value("status", std::string()); + int ecode = ej.value("code", 0); + std::string ereason; + if (ej.contains("errors") && ej["errors"].is_array() && !ej["errors"].empty()) { + auto e0 = ej["errors"][0]; + ereason = e0.value("reason", std::string()); + } + if (ecode || !emsg.empty() || !estatus.empty() || !ereason.empty()) { + obs_log( + LOG_WARNING, + "YouTube Chat API Error details: code=%d message=%s status=%s reason=%s", + ecode, emsg.c_str(), estatus.c_str(), ereason.c_str()); + } + } catch (...) { + } + } + + bool chatEnded = false; + try { + auto j = nlohmann::json::parse(response.toStdString()); + if (j.contains("error") && j["error"].is_object()) { + auto ej = j["error"]; + if (ej.contains("errors") && ej["errors"].is_array() && !ej["errors"].empty()) { + auto e0 = ej["errors"][0]; + std::string reason = e0.value("reason", std::string()); + if (reason == "liveChatEnded") + chatEnded = true; + } + std::string msg = ej.value("message", std::string()); + if (!chatEnded && msg.find("live chat is no longer live") != std::string::npos) + chatEnded = true; + } + } catch (...) { + } + + if (httpStatus == 403 && chatEnded) { + obs_log(LOG_INFO, + "YouTube liveChatId is no longer live; stopping polling and clearing chatId"); + m_isPolling = false; + m_pollingTimer->stop(); + m_statusTimer->stop(); + m_reconnectTimer->stop(); + m_nextPageToken.clear(); + m_liveChatId.clear(); + emit pollingStopped(); + wsBroadcast(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + emit errorOccurred("liveChatEnded", m_currentOperation); + return; + } + handleApiError(error, m_currentOperation, httpStatus); + scheduleReconnect(); + return; + } + + try { + nlohmann::json json = nlohmann::json::parse(response.toStdString()); + + YouTubeChatMessageListResponse chatResponse = parseChatMessageListResponse(json); + // obs_log(LOG_INFO, "YouTube chat API result: liveChatId=%s items=%d nextPageToken=%s + // pollIntervalMs=%d totalResults=%d", m_liveChatId.toUtf8().constData(), + // chatResponse.items.size(), chatResponse.nextPageToken.toUtf8().constData(), + // chatResponse.pollingIntervalMillis, chatResponse.totalResults); + + // Update next page token for pagination + m_nextPageToken = chatResponse.nextPageToken; + + // Emit the complete response + emit chatMessagesReceived(chatResponse); + + // Emit individual messages + for (const auto& message : chatResponse.items) { + emit newChatMessage(message); + + obs_log(LOG_DEBUG, "YouTube chat received: [%s] %s", + message.authorDetails.displayName.toUtf8().constData(), + message.snippet.displayMessage.toUtf8().constData()); + + try { + OneSevenLiveCoreManager::getInstance().enqueueOrBroadcastChatEvent( + QString::fromUtf8(ws::EventYouTubeChatMessage), toJson(message)); + } catch (const std::exception& e) { + obs_log(LOG_WARNING, "Failed to serialize/broadcast YouTube chat message: %s", + e.what()); + } + } + + // Reset retry count on successful request + m_currentRetryCount = 0; + m_exponentialBackoffDelay = m_retryDelayMs; + m_isRateLimited = false; + + if (chatResponse.items.isEmpty()) { + m_noMessageStreak++; + int interval = m_noMessageStreak <= MAX_NO_MESSAGE_QUICK_POLLS + ? qMin(chatResponse.pollingIntervalMillis, DEFAULT_POLLING_INTERVAL) + : chatResponse.pollingIntervalMillis; + scheduleNextPoll(interval); + } else { + m_noMessageStreak = 0; + scheduleNextPoll(chatResponse.pollingIntervalMillis); + } + + } catch (const std::exception& e) { + emit errorOccurred(QString("Failed to parse chat JSON response: ") + e.what(), + "parseChatResponse"); + scheduleReconnect(); + } +} + +void OneSevenLiveYouTubeChatClient::onPollingTimeout() { + if (!m_isPolling) { + return; + } + + fetchChatMessages(); +} + +void OneSevenLiveYouTubeChatClient::onStatusTimer() { + auto& core = OneSevenLiveCoreManager::getInstance(); + bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("YouTube"); + const char* status = (m_isPolling && isLive) ? "connected" : "break"; + core.enqueueOrBroadcastChatEvent(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", status}}); + + if (!isLive && m_isPolling) { + stopChatPolling(); + } +} + +void OneSevenLiveYouTubeChatClient::scheduleReconnect() { + if (!m_liveChatId.isEmpty()) { + bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("YouTube"); + if (!isLive) { + m_reconnectAttempts = 0; + m_isPolling = false; + wsBroadcast(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + return; + } + if (m_reconnectAttempts < MAX_QUICK_RETRIES) { + int delayMs = qMin(m_exponentialBackoffDelay, 5000); + m_reconnectAttempts++; + if (m_isPolling) { + m_pollingTimer->start(delayMs); + } else { + m_reconnectTimer->start(delayMs); + } + } else { + m_reconnectAttempts = 0; + m_isPolling = false; + wsBroadcast(QString::fromUtf8(ws::EventYouTubeChatConnected), + nlohmann::json{{"status", "break"}}); + m_reconnectTimer->start(LONG_RETRY_DELAY * 1000); + } + } +} + +void OneSevenLiveYouTubeChatClient::doReconnect() { + if (m_liveChatId.isEmpty()) + return; + bool isLive = OneSevenLiveMultiRtmpManager::getInstance()->isPlatformStreaming("YouTube"); + if (!isLive) + return; + startChatPolling(m_liveChatId); +} + +YouTubeChatMessage OneSevenLiveYouTubeChatClient::parseChatMessage( + const nlohmann::json& json) const { + YouTubeChatMessage message; + + message.kind = QString::fromStdString(json.value("kind", "")); + message.etag = QString::fromStdString(json.value("etag", "")); + message.id = QString::fromStdString(json.value("id", "")); + + if (json.contains("snippet") && json["snippet"].is_object()) { + message.snippet = parseMessageSnippet(json["snippet"]); + } + + if (json.contains("authorDetails") && json["authorDetails"].is_object()) { + message.authorDetails = parseAuthorDetails(json["authorDetails"]); + } + + return message; +} + +YouTubeChatMessageSnippet OneSevenLiveYouTubeChatClient::parseMessageSnippet( + const nlohmann::json& json) const { + YouTubeChatMessageSnippet snippet; + + snippet.type = QString::fromStdString(json.value("type", "")); + snippet.liveChatId = QString::fromStdString(json.value("liveChatId", "")); + snippet.authorChannelId = QString::fromStdString(json.value("authorChannelId", "")); + snippet.publishedAt = QString::fromStdString(json.value("publishedAt", "")); + snippet.displayMessage = QString::fromStdString(json.value("displayMessage", "")); + snippet.messageId = QString::fromStdString(json.value("messageId", "")); + + // Handle textMessageDetails if present + if (json.contains("textMessageDetails") && json["textMessageDetails"].is_object()) { + auto textDetails = json["textMessageDetails"]; + if (textDetails.contains("messageText")) { + snippet.textMessageDetails = + QString::fromStdString(textDetails.value("messageText", "")); + } + } + + return snippet; +} + +YouTubeChatAuthorDetails OneSevenLiveYouTubeChatClient::parseAuthorDetails( + const nlohmann::json& json) const { + YouTubeChatAuthorDetails author; + + author.channelId = QString::fromStdString(json.value("channelId", "")); + author.displayName = QString::fromStdString(json.value("displayName", "")); + author.profileImageUrl = QString::fromStdString(json.value("profileImageUrl", "")); + author.isVerified = json.value("isVerified", false); + author.isChatOwner = json.value("isChatOwner", false); + author.isChatSponsor = json.value("isChatSponsor", false); + author.isChatModerator = json.value("isChatModerator", false); + + return author; +} + +YouTubeChatMessageListResponse OneSevenLiveYouTubeChatClient::parseChatMessageListResponse( + const nlohmann::json& json) const { + YouTubeChatMessageListResponse response; + + response.kind = QString::fromStdString(json.value("kind", "")); + response.etag = QString::fromStdString(json.value("etag", "")); + response.nextPageToken = QString::fromStdString(json.value("nextPageToken", "")); + response.pollingIntervalMillis = json.value("pollingIntervalMillis", DEFAULT_POLLING_INTERVAL); + response.totalResults = json.value("totalResults", 0); + + if (json.contains("items") && json["items"].is_array()) { + for (const auto& item : json["items"]) { + if (item.is_object()) { + YouTubeChatMessage message = parseChatMessage(item); + response.items.append(message); + } + } + } + + return response; +} diff --git a/src/17live/youtube/OneSevenLiveYouTubeChatClient.hpp b/src/17live/youtube/OneSevenLiveYouTubeChatClient.hpp new file mode 100644 index 0000000..4ea1454 --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeChatClient.hpp @@ -0,0 +1,163 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "OneSevenLiveYouTubeClient.hpp" + +// Forward declarations +class RemoteTextThread; +class OneSevenLiveYouTubeClient; + +struct YouTubeChatMessageSnippet { + QString type; + QString liveChatId; + QString authorChannelId; + QString publishedAt; + QString displayMessage; + QString textMessageDetails; + QString messageId; + + YouTubeChatMessageSnippet() : type("textMessageEvent") {} +}; + +struct YouTubeChatAuthorDetails { + QString channelId; + QString displayName; + QString profileImageUrl; + bool isVerified; + bool isChatOwner; + bool isChatSponsor; + bool isChatModerator; + + YouTubeChatAuthorDetails() + : isVerified(false), isChatOwner(false), isChatSponsor(false), isChatModerator(false) {} +}; + +struct YouTubeChatMessage { + QString kind; + QString etag; + QString id; + YouTubeChatMessageSnippet snippet; + YouTubeChatAuthorDetails authorDetails; + + YouTubeChatMessage() : kind("youtube#liveChatMessage") {} +}; + +struct YouTubeChatMessageListResponse { + QString kind; + QString etag; + QString nextPageToken; + int pollingIntervalMillis; + int totalResults; + QVector items; + + YouTubeChatMessageListResponse() + : kind("youtube#liveChatMessageListResponse"), + pollingIntervalMillis(5000), + totalResults(0) {} +}; + +class OneSevenLiveYouTubeChatClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveYouTubeChatClient(QObject* parent = nullptr); + ~OneSevenLiveYouTubeChatClient(); + + // Authentication + void setAccessToken(const QString& accessToken); + bool hasValidAuth() const; + + // Chat Methods + void startChatPolling(const QString& liveChatId); + void stopChatPolling(); + bool isPolling() const; + + // Configuration + void setApiKey(const QString& apiKey); + void setTimeout(int timeoutMs); + void setMaxRetries(int maxRetries); + void setRetryDelay(int baseDelayMs); + void setApiClient(OneSevenLiveYouTubeClient* apiClient); + void startDiscovery(); + void stopDiscovery(); + + signals: + void chatMessagesReceived(const YouTubeChatMessageListResponse& response); + void newChatMessage(const YouTubeChatMessage& message); + void pollingStarted(const QString& liveChatId); + void pollingStopped(); + void errorOccurred(const QString& error, const QString& operation); + void rateLimitHit(int retryAfterMs); + + private slots: + void onChatRequestFinished(const QString& response, const QString& error); + void onPollingTimeout(); + void onStatusTimer(); + void doReconnect(); + void onBroadcastsReceived(const YouTubeLiveBroadcastListResponse& resp); + + private: + void fetchChatMessages(); + void scheduleNextPoll(int intervalMs); + void scheduleReconnect(); + void handleRateLimit(int retryAfterMs); + void handleApiError(const QString& error, const QString& operation, int httpStatus); + + // JSON parsing + YouTubeChatMessage parseChatMessage(const nlohmann::json& json) const; + YouTubeChatMessageSnippet parseMessageSnippet(const nlohmann::json& json) const; + YouTubeChatAuthorDetails parseAuthorDetails(const nlohmann::json& json) const; + YouTubeChatMessageListResponse parseChatMessageListResponse(const nlohmann::json& json) const; + + // Request building + QString buildChatMessagesUrl(const QString& liveChatId, + const QString& pageToken = QString()) const; + void makeChatRequest(const QString& endpoint); + + // Constants + static const QString YOUTUBE_API_BASE_URL; + static const QString YOUTUBE_API_VERSION; + static const int DEFAULT_POLLING_INTERVAL; + static const int MIN_POLLING_INTERVAL; + static const int MAX_EXPONENTIAL_BACKOFF_DELAY; + static const int STATUS_BROADCAST_INTERVAL; + static const int MAX_QUICK_RETRIES; + static const int LONG_RETRY_DELAY; + static const int MAX_NO_MESSAGE_QUICK_POLLS; + + // State + QString m_accessToken; + QString m_apiKey; + QString m_liveChatId; + QString m_nextPageToken; + OneSevenLiveYouTubeClient* m_apiClient{nullptr}; + int m_timeoutMs = 0; + int m_maxRetries = 0; + int m_retryDelayMs = 0; + int m_currentRetryCount = 0; + int m_reconnectAttempts = 0; + int m_noMessageStreak = 0; + bool m_hasValidAuth = false; + bool m_isPolling = false; + bool m_isRateLimited = false; + + // Polling + int m_currentPollingInterval = 0; + int m_exponentialBackoffDelay = 0; + + // Request context + QString m_currentOperation; + QString m_lastEndpoint; + + // Timer for polling + class QTimer* m_pollingTimer = nullptr; + class QTimer* m_statusTimer = nullptr; + class QTimer* m_reconnectTimer = nullptr; + class QTimer* m_discoverTimer{nullptr}; +}; diff --git a/src/17live/youtube/OneSevenLiveYouTubeClient.cpp b/src/17live/youtube/OneSevenLiveYouTubeClient.cpp new file mode 100644 index 0000000..6b44a75 --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeClient.cpp @@ -0,0 +1,606 @@ +#include "OneSevenLiveYouTubeClient.hpp" + +#include + +#include +#include +#include + +#include "OneSevenLiveCoreManager.hpp" +#include "plugin-support.h" +#include "utility/RemoteTextThread.hpp" + +const QString OneSevenLiveYouTubeClient::YOUTUBE_API_BASE_URL = + "https://www.googleapis.com/youtube/v3"; +const QString OneSevenLiveYouTubeClient::YOUTUBE_API_VERSION = "v3"; + +OneSevenLiveYouTubeClient::OneSevenLiveYouTubeClient(QObject* parent) + : QObject(parent), + m_timeoutMs(30000) // 30 seconds default timeout + , + m_hasValidAuth(false) {} + +OneSevenLiveYouTubeClient::~OneSevenLiveYouTubeClient() { + disconnect(this); +} + +void OneSevenLiveYouTubeClient::setAccessToken(const QString& accessToken) { + m_accessToken = accessToken; + m_hasValidAuth = !accessToken.isEmpty(); + obs_log(LOG_INFO, "YouTube access token set, valid: %s", m_hasValidAuth ? "true" : "false"); +} + +bool OneSevenLiveYouTubeClient::hasValidAuth() const { + return m_hasValidAuth; +} + +void OneSevenLiveYouTubeClient::getMyLiveStreams() { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "getMyLiveStreams"); + return; + } + + QMap params; + params["mine"] = "true"; + params["part"] = "snippet,cdn,status,contentDetails"; + + QString endpoint = buildApiUrl("liveStreams", params); + makeApiRequest(endpoint); +} + +void OneSevenLiveYouTubeClient::getLiveStreamById(const QString& streamId) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "getLiveStreamById"); + return; + } + + if (streamId.isEmpty()) { + emit errorOccurred("Stream ID cannot be empty", "getLiveStreamById"); + return; + } + + QMap params; + params["id"] = streamId; + params["part"] = "snippet,cdn,status,contentDetails"; + + QString endpoint = buildApiUrl("liveStreams", params); + makeApiRequest(endpoint); +} + +void OneSevenLiveYouTubeClient::createLiveStream(const QString& title, const QString& description) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "createLiveStream"); + return; + } + + if (title.isEmpty()) { + emit errorOccurred("Title cannot be empty", "createLiveStream"); + return; + } + + nlohmann::json requestBody; + nlohmann::json snippet; + snippet["title"] = title.toStdString(); + if (!description.isEmpty()) { + snippet["description"] = description.toStdString(); + } + requestBody["snippet"] = snippet; + + QString body = QString::fromStdString(requestBody.dump()); + + QMap params; + params["part"] = "snippet,cdn,status"; + + QString endpoint = buildApiUrl("liveStreams", params); + makeApiRequest(endpoint, "POST", body); +} + +void OneSevenLiveYouTubeClient::deleteLiveStream(const QString& streamId) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "deleteLiveStream"); + return; + } + + if (streamId.isEmpty()) { + emit errorOccurred("Stream ID cannot be empty", "deleteLiveStream"); + return; + } + + QMap params; + params["id"] = streamId; + + QString endpoint = buildApiUrl("liveStreams", params); + makeApiRequest(endpoint, "DELETE"); +} + +void OneSevenLiveYouTubeClient::getMyLiveBroadcasts(const QString& broadcastStatus) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "getMyLiveBroadcasts"); + return; + } + + QMap params; + params["mine"] = "true"; + params["part"] = "snippet"; + if (!broadcastStatus.isEmpty()) { + params["broadcastStatus"] = broadcastStatus; + } + if (m_hasValidAuth && !m_accessToken.isEmpty()) { + params["access_token"] = m_accessToken; + } + + QString endpoint = buildApiUrl("liveBroadcasts", params); + m_currentOperation = "getMyLiveBroadcasts"; + makeApiRequest(endpoint); +} + +void OneSevenLiveYouTubeClient::getLiveBroadcastById(const QString& broadcastId) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "getLiveBroadcastById"); + return; + } + if (broadcastId.isEmpty()) { + emit errorOccurred("Broadcast ID cannot be empty", "getLiveBroadcastById"); + return; + } + QMap params; + params["id"] = broadcastId; + params["part"] = "snippet,status"; + QString endpoint = buildApiUrl("liveBroadcasts", params); + m_currentOperation = "getLiveBroadcastById"; + makeApiRequest(endpoint); +} + +void OneSevenLiveYouTubeClient::setApiKey(const QString& apiKey) { + m_apiKey = apiKey; + obs_log(LOG_INFO, "YouTube API key set"); +} + +void OneSevenLiveYouTubeClient::setTimeout(int timeoutMs) { + m_timeoutMs = timeoutMs; + obs_log(LOG_INFO, "API timeout set to %d ms", timeoutMs); +} + +void OneSevenLiveYouTubeClient::createLiveBroadcast(const QString& title, + const QString& privacyStatus) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "createLiveBroadcast"); + return; + } + nlohmann::json req; + nlohmann::json sn; + sn["title"] = title.toStdString(); + req["snippet"] = sn; + nlohmann::json cd; + nlohmann::json mon; + mon["enableMonitorStream"] = true; + cd["monitorStream"] = mon; + req["contentDetails"] = cd; + nlohmann::json st; + st["privacyStatus"] = privacyStatus.toStdString(); + req["status"] = st; + QString body = QString::fromStdString(req.dump()); + QMap params; + params["part"] = "snippet,contentDetails,status"; + QString endpoint = buildApiUrl("liveBroadcasts", params); + m_currentOperation = "createLiveBroadcast"; + makeApiRequest(endpoint, "POST", body); +} + +void OneSevenLiveYouTubeClient::bindLiveBroadcast(const QString& broadcastId, + const QString& streamId) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "bindLiveBroadcast"); + return; + } + m_lastBroadcastId = broadcastId; + m_lastStreamId = streamId; + QMap params; + params["part"] = "id,contentDetails"; + params["id"] = broadcastId; + params["streamId"] = streamId; + QString endpoint = buildApiUrl("liveBroadcasts/bind", params); + m_currentOperation = "bindLiveBroadcast"; + makeApiRequest(endpoint, "POST"); +} + +void OneSevenLiveYouTubeClient::transitionLiveBroadcast(const QString& broadcastId, + const QString& status) { + if (!m_hasValidAuth) { + emit errorOccurred("No valid authentication token", "transitionLiveBroadcast"); + return; + } + m_lastBroadcastId = broadcastId; + m_lastTransitionStatus = status; + QMap params; + params["part"] = "status"; + params["id"] = broadcastId; + params["broadcastStatus"] = status; + QString endpoint = buildApiUrl("liveBroadcasts/transition", params); + m_currentOperation = "transitionLiveBroadcast"; + makeApiRequest(endpoint, "POST"); +} + +void OneSevenLiveYouTubeClient::makeApiRequest(const QString& endpoint, const QString& method, + const QString& body) { + obs_log(LOG_INFO, "YouTube API Request: %s %s", method.toUtf8().constData(), + endpoint.toUtf8().constData()); + if (!body.isEmpty()) { + obs_log(LOG_INFO, "Request body: %s", body.toUtf8().constData()); + } + m_lastEndpoint = endpoint; + m_lastMethod = method; + m_lastBody = body; + obs_log(LOG_DEBUG, "YouTube API auth present=%s token_len=%d", + m_hasValidAuth ? "true" : "false", m_accessToken.size()); + if (m_hasValidAuth) { + const QString tok = m_accessToken; + const QString masked = tok.length() >= 12 ? tok.left(6) + "..." + tok.right(6) : tok; + const char* mode = + (m_currentOperation == "getMyLiveBroadcasts") ? "QueryParam+Bearer" : "Bearer"; + obs_log(LOG_DEBUG, "YouTube API token(masked)=%s auth_mode=%s", masked.toUtf8().constData(), + mode); + } + + // Build headers + std::vector headers; + headers.push_back(std::string("Accept: application/json")); + if (method != "GET") { + headers.push_back(std::string("Content-Type: application/json")); + } + if (m_hasValidAuth && !m_accessToken.isEmpty()) { + std::string bearer = std::string("Authorization: Bearer ") + m_accessToken.toStdString(); + headers.push_back(bearer); + } + + std::atomic* cancelFlag = OneSevenLiveCoreManager::getInstance().getCancelFlag(); + RemoteTextThread* thread = new RemoteTextThread( + endpoint.toStdString(), std::move(headers), "application/json", + method == "POST" || method == "PUT" ? body.toStdString() : std::string(), + m_timeoutMs / 1000, false, cancelFlag); + + if (m_currentOperation.isEmpty()) { + if (m_currentOperation.isEmpty()) { + m_currentOperation = method == "GET" + ? "getLiveStreams" + : (method == "POST" ? "createLiveStream" : "API request"); + } + } + + connect(thread, &RemoteTextThread::Result, this, + &OneSevenLiveYouTubeClient::onApiRequestFinished, Qt::QueuedConnection); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + +QString OneSevenLiveYouTubeClient::buildApiUrl(const QString& endpoint, + const QMap& params) const { + QString url = YOUTUBE_API_BASE_URL + "/" + endpoint; + + QUrlQuery query; + for (auto it = params.constBegin(); it != params.constEnd(); ++it) { + query.addQueryItem(it.key(), it.value()); + } + if (!query.isEmpty()) { + url += "?" + query.toString(); + } + + return url; +} + +void OneSevenLiveYouTubeClient::onApiRequestFinished(const QString& response, + const QString& error) { + if (!error.isEmpty()) { + int httpStatus = -1; + QRegularExpression statusRegex(R"(HTTP (\d{3}))"); + QRegularExpressionMatch match = statusRegex.match(error); + if (match.hasMatch()) { + httpStatus = match.captured(1).toInt(); + } + if (httpStatus == -1) { + QRegularExpression returnedRegex(R"(returned error:\s*(\d{3}))"); + QRegularExpressionMatch m2 = returnedRegex.match(error); + if (m2.hasMatch()) + httpStatus = m2.captured(1).toInt(); + } + obs_log(LOG_WARNING, "YouTube API Error: status=%d op=%s method=%s endpoint=%s", httpStatus, + m_currentOperation.toUtf8().constData(), m_lastMethod.toUtf8().constData(), + m_lastEndpoint.toUtf8().constData()); + if (!response.isEmpty()) { + obs_log(LOG_WARNING, "YouTube API Error response: %s", response.toUtf8().constData()); + try { + auto j = nlohmann::json::parse(response.toStdString()); + auto ej = j.contains("error") ? j["error"] : nlohmann::json{}; + std::string emsg = ej.value("message", std::string()); + std::string estatus = ej.value("status", std::string()); + int ecode = ej.value("code", 0); + std::string ereason; + if (ej.contains("errors") && ej["errors"].is_array() && !ej["errors"].empty()) { + auto e0 = ej["errors"][0]; + ereason = e0.value("reason", std::string()); + } + if (ecode || !emsg.empty() || !estatus.empty() || !ereason.empty()) { + obs_log(LOG_WARNING, + "YouTube API Error details: code=%d message=%s status=%s reason=%s", + ecode, emsg.c_str(), estatus.c_str(), ereason.c_str()); + } + } catch (...) { + } + } + handleApiError(error, m_currentOperation, httpStatus); + return; + } + + // Assume success when error is empty + { + nlohmann::json json; + try { + json = nlohmann::json::parse(response.toStdString()); + } catch (const std::exception& e) { + emit errorOccurred(QString("Failed to parse JSON response: ") + e.what(), + "API request"); + return; + } + + // Determine the operation based on URL + if (m_currentOperation == "getLiveStreams") { + if (json.contains("items")) { + // This is a GET request for stream(s) + YouTubeLiveStreamListResponse streamList = parseLiveStreamListResponse(json); + emit myLiveStreamsReceived(streamList); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("getLiveStreams")); }, + Qt::QueuedConnection); + } else { + // This might be a POST request (create) + YouTubeLiveStream stream = parseLiveStream(json); + emit liveStreamCreated(stream); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("createLiveStream")); }, + Qt::QueuedConnection); + } + } else if (m_currentOperation == "getMyLiveBroadcasts") { + YouTubeLiveBroadcastListResponse broadcasts = parseLiveBroadcastListResponse(json); + emit myLiveBroadcastsReceived(broadcasts); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("getMyLiveBroadcasts")); }, + Qt::QueuedConnection); + } else if (m_currentOperation == "getLiveBroadcastById") { + YouTubeLiveBroadcast b; + try { + if (json.contains("items") && json["items"].is_array() && !json["items"].empty()) { + auto x = json["items"][0]; + b = parseLiveBroadcast(x); + } + } catch (...) { + } + emit liveBroadcastReceived(b); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("getLiveBroadcastById")); }, + Qt::QueuedConnection); + } else if (m_currentOperation == "createLiveBroadcast") { + QString id; + try { + if (json.contains("id") && json["id"].is_string()) { + id = QString::fromStdString(json["id"].get()); + } + } catch (...) { + } + emit liveBroadcastCreated(id); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("createLiveBroadcast")); }, + Qt::QueuedConnection); + } else if (m_currentOperation == "bindLiveBroadcast") { + emit liveBroadcastBound(m_lastBroadcastId, m_lastStreamId); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("bindLiveBroadcast")); }, + Qt::QueuedConnection); + } else if (m_currentOperation == "transitionLiveBroadcast") { + emit liveBroadcastTransitioned(m_lastBroadcastId, m_lastTransitionStatus); + QMetaObject::invokeMethod( + this, [this]() { emit requestCompleted(QString("transitionLiveBroadcast")); }, + Qt::QueuedConnection); + } + } +} + +void OneSevenLiveYouTubeClient::onApiRequestError(const QString& response, const QString& error) { + obs_log(LOG_WARNING, "YouTube API Error: %s : %s", error.toUtf8().constData(), + response.toUtf8().constData()); + handleApiError(error, m_currentOperation, -1); +} + +void OneSevenLiveYouTubeClient::handleApiError(const QString& error, const QString& operation, + int httpStatus) { + QString detailedError; + + switch (httpStatus) { + case 401: + detailedError = "Authentication failed - invalid or expired token"; + m_hasValidAuth = false; + break; + case 403: + detailedError = "Access forbidden - insufficient permissions"; + break; + case 404: + detailedError = "Resource not found"; + break; + case 429: + detailedError = "Rate limit exceeded"; + break; + default: + detailedError = error; + break; + } + + emit errorOccurred(detailedError, operation); +} + +YouTubeLiveStream OneSevenLiveYouTubeClient::parseLiveStream(const nlohmann::json& json) const { + YouTubeLiveStream stream; + + stream.kind = QString::fromStdString(json.value("kind", "")); + stream.etag = QString::fromStdString(json.value("etag", "")); + stream.id = QString::fromStdString(json.value("id", "")); + + if (json.contains("snippet") && json["snippet"].is_object()) { + stream.snippet = parseSnippet(json["snippet"]); + } + + if (json.contains("cdn") && json["cdn"].is_object()) { + stream.cdn = parseCdn(json["cdn"]); + } + + if (json.contains("status") && json["status"].is_object()) { + stream.status = parseStatus(json["status"]); + } + + if (json.contains("contentDetails") && json["contentDetails"].is_object()) { + stream.contentDetails = parseContentDetails(json["contentDetails"]); + } + + return stream; +} + +YouTubeLiveStreamSnippet OneSevenLiveYouTubeClient::parseSnippet(const nlohmann::json& json) const { + YouTubeLiveStreamSnippet snippet; + + snippet.publishedAt = QString::fromStdString(json.value("publishedAt", "")); + snippet.channelId = QString::fromStdString(json.value("channelId", "")); + snippet.title = QString::fromStdString(json.value("title", "")); + snippet.description = QString::fromStdString(json.value("description", "")); + snippet.isDefaultStream = json.value("isDefaultStream", false); + + return snippet; +} + +YouTubeLiveStreamCdn OneSevenLiveYouTubeClient::parseCdn(const nlohmann::json& json) const { + YouTubeLiveStreamCdn cdn; + + cdn.ingestionType = QString::fromStdString(json.value("ingestionType", "")); + cdn.resolution = QString::fromStdString(json.value("resolution", "")); + cdn.frameRate = QString::fromStdString(json.value("frameRate", "")); + + if (json.contains("ingestionInfo") && json["ingestionInfo"].is_object()) { + cdn.ingestionInfo = parseIngestionInfo(json["ingestionInfo"]); + } + + return cdn; +} + +YouTubeLiveStreamIngestionInfo OneSevenLiveYouTubeClient::parseIngestionInfo( + const nlohmann::json& json) const { + YouTubeLiveStreamIngestionInfo info; + + info.streamName = QString::fromStdString(json.value("streamName", "")); + info.ingestionAddress = QString::fromStdString(json.value("ingestionAddress", "")); + info.backupIngestionAddress = QString::fromStdString(json.value("backupIngestionAddress", "")); + info.rtmpsIngestionAddress = QString::fromStdString(json.value("rtmpsIngestionAddress", "")); + info.rtmpsBackupIngestionAddress = + QString::fromStdString(json.value("rtmpsBackupIngestionAddress", "")); + + return info; +} + +YouTubeLiveStreamStatus OneSevenLiveYouTubeClient::parseStatus(const nlohmann::json& json) const { + YouTubeLiveStreamStatus status; + + status.streamStatus = QString::fromStdString(json.value("streamStatus", "")); + + if (json.contains("healthStatus") && json["healthStatus"].is_object()) { + status.healthStatus = parseHealthStatus(json["healthStatus"]); + } + + return status; +} + +YouTubeLiveStreamHealthStatus OneSevenLiveYouTubeClient::parseHealthStatus( + const nlohmann::json& json) const { + YouTubeLiveStreamHealthStatus healthStatus; + healthStatus.status = QString::fromStdString(json.value("status", "")); + return healthStatus; +} + +YouTubeLiveStreamContentDetails OneSevenLiveYouTubeClient::parseContentDetails( + const nlohmann::json& json) const { + YouTubeLiveStreamContentDetails details; + + details.closedCaptionsIngestionUrl = + QString::fromStdString(json.value("closedCaptionsIngestionUrl", "")); + details.isReusable = json.value("isReusable", true); + + return details; +} + +YouTubeLiveStreamListResponse OneSevenLiveYouTubeClient::parseLiveStreamListResponse( + const nlohmann::json& json) const { + YouTubeLiveStreamListResponse response; + + response.kind = QString::fromStdString(json.value("kind", "")); + response.etag = QString::fromStdString(json.value("etag", "")); + + if (json.contains("pageInfo") && json["pageInfo"].is_object()) { + auto pageInfo = json["pageInfo"]; + response.pageInfo.totalResults = pageInfo.value("totalResults", 0); + response.pageInfo.resultsPerPage = pageInfo.value("resultsPerPage", 0); + } + + if (json.contains("items") && json["items"].is_array()) { + for (const auto& item : json["items"]) { + if (item.is_object()) { + YouTubeLiveStream stream = parseLiveStream(item); + response.items.append(stream); + } + } + } + + return response; +} + +YouTubeLiveBroadcastSnippet OneSevenLiveYouTubeClient::parseLiveBroadcastSnippet( + const nlohmann::json& json) const { + YouTubeLiveBroadcastSnippet snippet; + snippet.title = QString::fromStdString(json.value("title", "")); + snippet.channelId = QString::fromStdString(json.value("channelId", "")); + snippet.scheduledStartTime = QString::fromStdString(json.value("scheduledStartTime", "")); + snippet.actualStartTime = QString::fromStdString(json.value("actualStartTime", "")); + snippet.liveChatId = QString::fromStdString(json.value("liveChatId", "")); + return snippet; +} + +YouTubeLiveBroadcastStatus OneSevenLiveYouTubeClient::parseLiveBroadcastStatus( + const nlohmann::json& json) const { + YouTubeLiveBroadcastStatus status; + status.lifeCycleStatus = QString::fromStdString(json.value("lifeCycleStatus", "")); + return status; +} + +YouTubeLiveBroadcast OneSevenLiveYouTubeClient::parseLiveBroadcast( + const nlohmann::json& json) const { + YouTubeLiveBroadcast b; + b.kind = QString::fromStdString(json.value("kind", "")); + b.etag = QString::fromStdString(json.value("etag", "")); + b.id = QString::fromStdString(json.value("id", "")); + if (json.contains("snippet") && json["snippet"].is_object()) { + b.snippet = parseLiveBroadcastSnippet(json["snippet"]); + } + if (json.contains("status") && json["status"].is_object()) { + b.status = parseLiveBroadcastStatus(json["status"]); + } + return b; +} + +YouTubeLiveBroadcastListResponse OneSevenLiveYouTubeClient::parseLiveBroadcastListResponse( + const nlohmann::json& json) const { + YouTubeLiveBroadcastListResponse resp; + resp.kind = QString::fromStdString(json.value("kind", "")); + resp.etag = QString::fromStdString(json.value("etag", "")); + if (json.contains("items") && json["items"].is_array()) { + for (const auto& item : json["items"]) { + if (item.is_object()) { + resp.items.append(parseLiveBroadcast(item)); + } + } + } + return resp; +} diff --git a/src/17live/youtube/OneSevenLiveYouTubeClient.hpp b/src/17live/youtube/OneSevenLiveYouTubeClient.hpp new file mode 100644 index 0000000..f76af0f --- /dev/null +++ b/src/17live/youtube/OneSevenLiveYouTubeClient.hpp @@ -0,0 +1,202 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Forward declarations +class RemoteTextThread; + +struct YouTubeLiveStreamSnippet { + QString publishedAt; + QString channelId; + QString title; + QString description; + bool isDefaultStream; + + YouTubeLiveStreamSnippet() : isDefaultStream(false) {} +}; + +struct YouTubeLiveStreamIngestionInfo { + QString streamName; + QString ingestionAddress; + QString backupIngestionAddress; + QString rtmpsIngestionAddress; + QString rtmpsBackupIngestionAddress; +}; + +struct YouTubeLiveStreamCdn { + QString ingestionType; + YouTubeLiveStreamIngestionInfo ingestionInfo; + QString resolution; + QString frameRate; + + YouTubeLiveStreamCdn() : ingestionType("rtmp"), resolution("variable"), frameRate("variable") {} +}; + +struct YouTubeLiveStreamHealthStatus { + QString status; +}; + +struct YouTubeLiveStreamStatus { + QString streamStatus; + YouTubeLiveStreamHealthStatus healthStatus; + + YouTubeLiveStreamStatus() : streamStatus("inactive") {} +}; + +struct YouTubeLiveStreamContentDetails { + QString closedCaptionsIngestionUrl; + bool isReusable; + + YouTubeLiveStreamContentDetails() : isReusable(true) {} +}; + +struct YouTubeLiveStream { + QString kind; + QString etag; + QString id; + YouTubeLiveStreamSnippet snippet; + YouTubeLiveStreamCdn cdn; + YouTubeLiveStreamStatus status; + YouTubeLiveStreamContentDetails contentDetails; + + YouTubeLiveStream() : kind("youtube#liveStream") {} +}; + +struct YouTubeLiveStreamListResponse { + QString kind; + QString etag; + + struct { + int totalResults; + int resultsPerPage; + } pageInfo; + + QVector items; + + YouTubeLiveStreamListResponse() : kind("youtube#liveStreamListResponse"), pageInfo{0, 5} {} +}; + +struct YouTubeLiveBroadcastSnippet { + QString liveChatId; + QString title; + QString channelId; + QString scheduledStartTime; + QString actualStartTime; + + YouTubeLiveBroadcastSnippet() {} +}; + +struct YouTubeLiveBroadcastStatus { + QString lifeCycleStatus; + + YouTubeLiveBroadcastStatus() {} +}; + +struct YouTubeLiveBroadcast { + QString kind; + QString etag; + QString id; + YouTubeLiveBroadcastSnippet snippet; + YouTubeLiveBroadcastStatus status; + + YouTubeLiveBroadcast() : kind("youtube#liveBroadcast") {} +}; + +struct YouTubeLiveBroadcastListResponse { + QString kind; + QString etag; + QVector items; + + YouTubeLiveBroadcastListResponse() : kind("youtube#liveBroadcastListResponse") {} +}; + +class OneSevenLiveYouTubeClient : public QObject { + Q_OBJECT + + public: + explicit OneSevenLiveYouTubeClient(QObject* parent = nullptr); + ~OneSevenLiveYouTubeClient(); + + // Authentication + void setAccessToken(const QString& accessToken); + bool hasValidAuth() const; + + // API Methods + void getMyLiveStreams(); + void getLiveStreamById(const QString& streamId); + void createLiveStream(const QString& title, const QString& description = QString()); + void deleteLiveStream(const QString& streamId); + void getMyLiveBroadcasts(const QString& broadcastStatus = QString()); + void getLiveBroadcastById(const QString& broadcastId); + void createLiveBroadcast(const QString& title, const QString& privacyStatus = "public"); + void bindLiveBroadcast(const QString& broadcastId, const QString& streamId); + void transitionLiveBroadcast(const QString& broadcastId, const QString& status); + + // Configuration + void setApiKey(const QString& apiKey); + void setTimeout(int timeoutMs); + + signals: + void myLiveStreamsReceived(const YouTubeLiveStreamListResponse& response); + void liveStreamReceived(const YouTubeLiveStream& stream); + void liveStreamCreated(const YouTubeLiveStream& stream); + void liveStreamDeleted(const QString& streamId); + void myLiveBroadcastsReceived(const YouTubeLiveBroadcastListResponse& response); + void liveBroadcastReceived(const YouTubeLiveBroadcast& broadcast); + void liveBroadcastCreated(const QString& broadcastId); + void liveBroadcastBound(const QString& broadcastId, const QString& streamId); + void liveBroadcastTransitioned(const QString& broadcastId, const QString& status); + void errorOccurred(const QString& error, const QString& operation); + void requestCompleted(const QString& operation); + + private slots: + void onApiRequestFinished(const QString& response, const QString& error); + void onApiRequestError(const QString& response, const QString& error); + + private: + void makeApiRequest(const QString& endpoint, const QString& method = "GET", + const QString& body = QString()); + QString m_lastBroadcastId; + QString m_lastStreamId; + QString m_lastTransitionStatus; + QString buildApiUrl(const QString& endpoint, const QMap& params) const; + + // JSON parsing + YouTubeLiveStream parseLiveStream(const nlohmann::json& json) const; + YouTubeLiveStreamSnippet parseSnippet(const nlohmann::json& json) const; + YouTubeLiveStreamCdn parseCdn(const nlohmann::json& json) const; + YouTubeLiveStreamIngestionInfo parseIngestionInfo(const nlohmann::json& json) const; + YouTubeLiveStreamStatus parseStatus(const nlohmann::json& json) const; + YouTubeLiveStreamHealthStatus parseHealthStatus(const nlohmann::json& json) const; + YouTubeLiveStreamContentDetails parseContentDetails(const nlohmann::json& json) const; + YouTubeLiveStreamListResponse parseLiveStreamListResponse(const nlohmann::json& json) const; + YouTubeLiveBroadcastSnippet parseLiveBroadcastSnippet(const nlohmann::json& json) const; + YouTubeLiveBroadcastStatus parseLiveBroadcastStatus(const nlohmann::json& json) const; + YouTubeLiveBroadcast parseLiveBroadcast(const nlohmann::json& json) const; + YouTubeLiveBroadcastListResponse parseLiveBroadcastListResponse( + const nlohmann::json& json) const; + + // Error handling + void handleApiError(const QString& error, const QString& operation, int httpStatus); + + // Constants + static const QString YOUTUBE_API_BASE_URL; + static const QString YOUTUBE_API_VERSION; + + // State + QString m_accessToken; + QString m_apiKey; + int m_timeoutMs = 30000; // Default timeout + bool m_hasValidAuth = false; + + // Request context + QString m_currentOperation; + QString m_lastEndpoint; + QString m_lastMethod; + QString m_lastBody; +}; diff --git a/src/diag/DiagnosticsCollectorBase.cpp b/src/diag/DiagnosticsCollectorBase.cpp new file mode 100644 index 0000000..3bd50b8 --- /dev/null +++ b/src/diag/DiagnosticsCollectorBase.cpp @@ -0,0 +1,281 @@ +#include "DiagnosticsCollectorBase.hpp" + +#include +#include + +#include "PrivacyFilter.hpp" + +namespace seventeen { + namespace diag { + + DiagnosticsCollectorBase::DiagnosticsCollectorBase() + : m_privacyFilter(std::make_unique()) {} + + CollectResult DiagnosticsCollectorBase::collect(const DiagnosticConfig& config) { + CollectResult result; + result.status = CollectStatus::SUCCESS; + + try { + std::string tempDir = generateTempDirectory(); + if (tempDir.empty()) { + result.status = CollectStatus::ERROR; + result.message = "Failed to create temporary directory"; + return result; + } + + reportProgress("Initializing collection...", 0.0); + + std::vector allFiles; + // Add 1 for the archive step + double progressStep = 1.0 / (config.categories.size() + 1); + double currentProgress = 0.0; + + m_currentStageScale = progressStep; + + for (const auto& category : config.categories) { + std::string categoryName = [category]() { + switch (category) { + case DiagnosticCategory::OBS_LOGS: + return "OBS logs"; + case DiagnosticCategory::PLUGIN_LOGS: + return "Plugin logs"; + case DiagnosticCategory::NETWORK_LOGS: + return "Network logs"; + case DiagnosticCategory::SYSTEM_INFO: + return "System information"; + case DiagnosticCategory::CRASH_INFO: + return "Crash information"; + case DiagnosticCategory::CONFIG_SNAPSHOT: + return "Configuration snapshot"; + case DiagnosticCategory::NETWORK_REQUESTS: + return "Network requests"; + default: + return "Unknown"; + } + }(); + + m_currentBaseProgress = currentProgress; + reportSubProgress("Start collecting " + categoryName + "...", 0.0); + + auto categoryFiles = collectCategory(category); + for (const auto& file : categoryFiles) { + if (config.enablePrivacyFilter && !applyPrivacyFilter(file)) { + continue; + } + allFiles.push_back(file); + } + + currentProgress += progressStep; + } + + if (allFiles.empty()) { + result.status = CollectStatus::ERROR; + result.message = "No files were collected"; + return result; + } + + m_currentBaseProgress = currentProgress; + reportSubProgress("Creating archive...", 0.0); + + std::string outputPath = config.outputDirectory; + if (outputPath.empty()) { + outputPath = + (std::filesystem::path(std::filesystem::temp_directory_path()) / + ("diagnostics_" + + std::to_string( + std::chrono::system_clock::now().time_since_epoch().count()) + + ".zip")) + .string(); + } + + if (!createZipArchive(outputPath, allFiles)) { + result.status = CollectStatus::ERROR; + result.message = "Failed to create ZIP archive: " + getLastError(); + return result; + } + + result.outputPath = outputPath; + result.collectedFiles = allFiles; + result.message = "Diagnostics collection completed successfully"; + + reportProgress("Collection completed", 1.0); + + } catch (const std::exception& e) { + result.status = CollectStatus::ERROR; + result.message = std::string("Exception during collection: ") + e.what(); + } + + return result; + } + + std::vector DiagnosticsCollectorBase::getAvailableCategories() const { + return {DiagnosticCategory::OBS_LOGS, DiagnosticCategory::PLUGIN_LOGS, + DiagnosticCategory::NETWORK_LOGS, DiagnosticCategory::SYSTEM_INFO, + DiagnosticCategory::CRASH_INFO, DiagnosticCategory::CONFIG_SNAPSHOT, + DiagnosticCategory::NETWORK_REQUESTS}; + } + + std::vector DiagnosticsCollectorBase::collectCategory( + DiagnosticCategory category) { + switch (category) { + case DiagnosticCategory::OBS_LOGS: + return collectOBSLogs(); + case DiagnosticCategory::PLUGIN_LOGS: + return collectPluginLogs(); + case DiagnosticCategory::NETWORK_LOGS: + return collectNetworkLogs(); + case DiagnosticCategory::SYSTEM_INFO: + return {writeSystemInfoToFile()}; + case DiagnosticCategory::CRASH_INFO: + return collectCrashInfo(); + case DiagnosticCategory::CONFIG_SNAPSHOT: + return collectConfigSnapshot(); + case DiagnosticCategory::NETWORK_REQUESTS: + return collectNetworkRequests(); + default: + return {}; + } + } + + bool DiagnosticsCollectorBase::applyPrivacyFilter(const std::string& filePath) { + if (!m_privacyFilter) { + return true; + } + + std::ifstream file(filePath); + if (!file.is_open()) { + return false; + } + + std::stringstream buffer; + buffer << file.rdbuf(); + file.close(); + + std::string filteredContent = m_privacyFilter->filterSensitiveData(buffer.str()); + + std::ofstream outFile(filePath); + if (!outFile.is_open()) { + return false; + } + + outFile << filteredContent; + return true; + } + + std::string DiagnosticsCollectorBase::generateTempDirectory() { + try { + auto now = std::chrono::system_clock::now(); + auto timestamp = + std::chrono::duration_cast(now.time_since_epoch()) + .count(); + + std::string tempDir = (std::filesystem::temp_directory_path() / + ("17live_diagnostics_" + std::to_string(timestamp))) + .string(); + + std::filesystem::create_directories(tempDir); + return tempDir; + } catch (const std::exception& e) { + setLastError(std::string("Failed to create temp directory: ") + e.what()); + return ""; + } + } + + std::string DiagnosticsCollectorBase::sanitizeFileName(const std::string& filename) { + std::string sanitized = filename; + std::regex invalidChars(R"([<>:"/\\|?*])"); + sanitized = std::regex_replace(sanitized, invalidChars, "_"); + return sanitized; + } + + bool DiagnosticsCollectorBase::writeToFile(const std::string& path, + const std::string& content) { + try { + std::filesystem::create_directories(std::filesystem::path(path).parent_path()); + + std::ofstream file(path); + if (!file.is_open()) { + setLastError("Failed to open file for writing: " + path); + return false; + } + + file << content; + return true; + } catch (const std::exception& e) { + setLastError(std::string("Failed to write file: ") + e.what()); + return false; + } + } + + void DiagnosticsCollectorBase::reportProgress(const std::string& stage, double progress) { + if (m_progressCallback) { + m_progressCallback(stage, progress); + } + } + + void DiagnosticsCollectorBase::reportSubProgress(const std::string& detail, + double subProgress) { + double totalProgress = m_currentBaseProgress + (subProgress * m_currentStageScale); + if (totalProgress > 1.0) + totalProgress = 1.0; + reportProgress(detail, totalProgress); + } + + std::string DiagnosticsCollectorBase::getSystemInfo() const { + std::stringstream ss; + ss << "Platform: " << getPlatformName() << std::endl; + ss << getSystemInfoImpl(); + return ss.str(); + } + + std::string DiagnosticsCollectorBase::writeSystemInfoToFile() { + std::string tempDir = generateTempDirectory(); + if (tempDir.empty()) { + return ""; + } + + std::string filePath = (std::filesystem::path(tempDir) / "systeminfo.txt").string(); + std::string systemInfo = getSystemInfo(); + + if (writeToFile(filePath, systemInfo)) { + return filePath; + } + + return ""; + } + + bool DiagnosticsCollectorBase::copyWithSizeLimit(const std::string& source, + const std::string& destination, + std::uintmax_t maxBytes) { + try { + if (!std::filesystem::exists(source)) { + return false; + } + std::filesystem::create_directories( + std::filesystem::path(destination).parent_path()); + auto size = std::filesystem::file_size(source); + if (size <= maxBytes) { + std::filesystem::copy_file(source, destination, + std::filesystem::copy_options::overwrite_existing); + return true; + } + // Create a marker .txt noting the original file path when too large + std::string marker = destination; + if (std::filesystem::path(destination).extension() != ".txt") { + marker = (std::filesystem::path(destination).parent_path() / + (std::filesystem::path(destination).filename().string() + ".txt")) + .string(); + } + std::stringstream ss; + ss << "File exceeds size limit (" << maxBytes << " bytes).\n"; + ss << "Original path: " << source << "\n"; + ss << "Size: " << size << " bytes\n"; + return writeToFile(marker, ss.str()); + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy with size limit: ") + e.what()); + return false; + } + } + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorBase.hpp b/src/diag/DiagnosticsCollectorBase.hpp new file mode 100644 index 0000000..9ddba1e --- /dev/null +++ b/src/diag/DiagnosticsCollectorBase.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "IDiagnosticsCollector.hpp" +#include "PrivacyFilter.hpp" + +namespace seventeen { + namespace diag { + + class DiagnosticsCollectorBase : public IDiagnosticsCollector { + public: + DiagnosticsCollectorBase(); + virtual ~DiagnosticsCollectorBase() = default; + + CollectResult collect(const DiagnosticConfig& config) override; + + std::vector getAvailableCategories() const override; + + void setProgressCallback(ProgressCallback callback) override { + m_progressCallback = callback; + } + + std::string getLastError() const override { + return m_lastError; + } + + protected: + virtual std::string getPlatformName() const = 0; + std::string getSystemInfo() const override; + virtual std::string getSystemInfoImpl() const = 0; + virtual std::vector collectOBSLogs() = 0; + virtual std::vector collectPluginLogs() = 0; + virtual std::vector collectNetworkLogs() = 0; + virtual std::vector collectCrashInfo() = 0; + virtual std::vector collectConfigSnapshot() = 0; + virtual std::vector collectNetworkRequests() = 0; + virtual bool createZipArchive(const std::string& outputPath, + const std::vector& files) = 0; + + void reportProgress(const std::string& stage, double progress); + void reportSubProgress(const std::string& detail, double subProgress); + + void setLastError(const std::string& error) { + m_lastError = error; + } + + std::string generateTempDirectory(); + std::string sanitizeFileName(const std::string& filename); + bool writeToFile(const std::string& path, const std::string& content); + std::string writeSystemInfoToFile(); + bool copyWithSizeLimit(const std::string& source, const std::string& destination, + std::uintmax_t maxBytes = 1024 * 1024); + + std::unique_ptr m_privacyFilter; + + private: + ProgressCallback m_progressCallback; + std::string m_lastError; + + double m_currentBaseProgress = 0.0; + double m_currentStageScale = 1.0; + + std::vector collectCategory(DiagnosticCategory category); + bool applyPrivacyFilter(const std::string& filePath); + }; + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorFactory.cpp b/src/diag/DiagnosticsCollectorFactory.cpp new file mode 100644 index 0000000..e668734 --- /dev/null +++ b/src/diag/DiagnosticsCollectorFactory.cpp @@ -0,0 +1,23 @@ +#include "IDiagnosticsCollector.hpp" + +#ifdef __APPLE__ +#include "DiagnosticsCollectorMacOS.hpp" +#elif defined(_WIN32) +#include "DiagnosticsCollectorWindows.hpp" +#endif + +namespace seventeen { + namespace diag { + + std::unique_ptr createDiagnosticsCollector() { +#ifdef __APPLE__ + return std::make_unique(); +#elif defined(_WIN32) + return std::make_unique(); +#else + return nullptr; +#endif + } + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorFactory.hpp b/src/diag/DiagnosticsCollectorFactory.hpp new file mode 100644 index 0000000..205eca0 --- /dev/null +++ b/src/diag/DiagnosticsCollectorFactory.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "IDiagnosticsCollector.hpp" + +namespace seventeen { + namespace diag { + + std::unique_ptr createDiagnosticsCollector(); + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorMacOS.cpp b/src/diag/DiagnosticsCollectorMacOS.cpp new file mode 100644 index 0000000..90976e3 --- /dev/null +++ b/src/diag/DiagnosticsCollectorMacOS.cpp @@ -0,0 +1,565 @@ +#include "DiagnosticsCollectorMacOS.hpp" + +#include +#include +#include +#include + +namespace seventeen { + namespace diag { + + DiagnosticsCollectorMacOS::DiagnosticsCollectorMacOS() {} + + std::string DiagnosticsCollectorMacOS::getSystemInfoImpl() const { + std::stringstream ss; + + ss << "macOS Version: " << executeCommand("sw_vers -productVersion") << std::endl; + ss << "Build Version: " << executeCommand("sw_vers -buildVersion") << std::endl; + + ss << "\nHardware Information:" << std::endl; + ss << "Model: " << executeCommand("sysctl -n hw.model") << std::endl; + ss << "CPU: " << executeCommand("sysctl -n machdep.cpu.brand_string") << std::endl; + ss << "Memory: " << executeCommand("sysctl -n hw.memsize") << " bytes" << std::endl; + ss << "Cores: " << executeCommand("sysctl -n hw.ncpu") << std::endl; + + ss << "\nGraphics Information:" << std::endl; + ss << executeCommand( + "system_profiler SPDisplaysDataType | grep -E '(Chipset Model|VRAM|Metal)'") + << std::endl; + + ss << "\nDisk Information:" << std::endl; + ss << executeCommand("df -h /") << std::endl; + + return ss.str(); + } + + std::vector DiagnosticsCollectorMacOS::collectOBSLogs() { + std::vector logFiles; + std::string obsLogDir = getOBSLogDirectory(); + + if (std::filesystem::exists(obsLogDir)) { + auto files = getFilesInDirectory(obsLogDir, "*.txt"); + std::sort(files.begin(), files.end(), + [](const std::string& a, const std::string& b) { + return std::filesystem::last_write_time(a) > + std::filesystem::last_write_time(b); + }); + std::string tempDir = generateTempDirectory(); + double total = files.size(); + for (size_t i = 0; i < files.size(); ++i) { + const auto& file = files[i]; + std::string fileName = std::filesystem::path(file).filename().string(); + reportSubProgress("Collecting OBS log: " + fileName, (double) i / total); + std::string destPath = std::filesystem::path(tempDir) / ("obs_" + fileName); + if (copyWithSizeLimit(file, destPath)) { + logFiles.push_back(destPath); + } + } + } + + return logFiles; + } + + std::vector DiagnosticsCollectorMacOS::collectPluginLogs() { + std::vector logFiles; + + // Candidate plugin log directories under OBS plugin_config and legacy ~/.17Live/logs + std::string homeDir = getHomeDirectory(); + std::vector candidates = { + homeDir + "/Library/Application Support/obs-studio/plugin_config/17live/logs", + homeDir + "/Library/Application Support/obs-studio/plugin_config/obs-17live/logs", + homeDir + "/.17Live/logs"}; + + std::vector files; + for (const auto& dir : candidates) { + if (!std::filesystem::exists(dir)) + continue; + try { + for (const auto& entry : std::filesystem::directory_iterator(dir)) { + if (!entry.is_regular_file()) + continue; + std::string name = entry.path().filename().string(); + if (name.find(".log") != std::string::npos) { + files.push_back(entry.path().string()); + } + } + } catch (const std::exception& e) { + setLastError(std::string("Error reading plugin logs: ") + e.what()); + } + } + + // Sort by modification time desc and choose latest 5 + std::sort(files.begin(), files.end(), [](const std::string& a, const std::string& b) { + return std::filesystem::last_write_time(a) > std::filesystem::last_write_time(b); + }); + if (files.size() > 5) + files.resize(5); + + std::string tempDir = generateTempDirectory(); + double total = files.size(); + for (size_t i = 0; i < files.size(); ++i) { + const auto& file = files[i]; + std::string fileName = std::filesystem::path(file).filename().string(); + reportSubProgress("Collecting plugin log: " + fileName, (double) i / total); + std::string destPath = std::filesystem::path(tempDir) / ("plugin_" + fileName); + if (copyWithSizeLimit(file, destPath)) { + logFiles.push_back(destPath); + } + } + + return logFiles; + } + + std::vector DiagnosticsCollectorMacOS::collectNetworkLogs() { + std::vector networkLogs; + + std::string tempDir = generateTempDirectory(); + std::string networkInfoPath = std::filesystem::path(tempDir) / "network_info.txt"; + + std::stringstream ss; + ss << "Network Interfaces:" << std::endl; + ss << executeCommand("ifconfig") << std::endl; + ss << "\nNetwork Statistics:" << std::endl; + ss << executeCommand("netstat -i") << std::endl; + ss << "\nRouting Table:" << std::endl; + ss << executeCommand("netstat -r") << std::endl; + + if (writeToFile(networkInfoPath, ss.str())) { + networkLogs.push_back(networkInfoPath); + } + + return networkLogs; + } + + std::vector DiagnosticsCollectorMacOS::collectCrashInfo() { + std::vector crashFiles; + std::string homeDir = getHomeDirectory(); + // macOS crash reports locations + std::vector crashDirs = {homeDir + "/Library/Logs/DiagnosticReports", + std::string("/Library/Logs/DiagnosticReports")}; + + auto now = std::chrono::system_clock::now(); + auto cutoff = now - std::chrono::hours(24 * 30); // 30 days + auto cutoff_fs = std::filesystem::file_time_type::clock::now() - + (std::chrono::system_clock::now() - cutoff); + + std::string tempDir = generateTempDirectory(); + + // Temporary vector to store valid crash files with their modification times + struct CrashFileEntry { + std::filesystem::path path; + std::filesystem::file_time_type mtime; + }; + + std::vector foundCrashes; + + for (const auto& dir : crashDirs) { + if (!std::filesystem::exists(dir)) + continue; + try { + // Use recursive iterator to support subdirectories like "Retired" + for (const auto& entry : std::filesystem::recursive_directory_iterator(dir)) { + if (!entry.is_regular_file()) + continue; + + auto name = entry.path().filename().string(); + // Check for .crash or .ips extensions + bool isCrashFile = (entry.path().extension() == ".crash" || + entry.path().extension() == ".ips"); + // Check for obs or OBS prefix (without underscore) + bool isOBS = (name.rfind("obs", 0) == 0 || name.rfind("OBS", 0) == 0); + + if (isCrashFile && isOBS) { + try { + auto mtime = std::filesystem::last_write_time(entry.path()); + if (mtime >= cutoff_fs) { + foundCrashes.push_back({entry.path(), mtime}); + } + } catch (...) { + // Skip file if mtime cannot be read + } + } + } + } catch (const std::exception& e) { + setLastError(std::string("Error reading crash reports: ") + e.what()); + } + } + + // Sort by modification time descending + std::sort( + foundCrashes.begin(), foundCrashes.end(), + [](const CrashFileEntry& a, const CrashFileEntry& b) { return a.mtime > b.mtime; }); + + // Keep top 5 latest + if (foundCrashes.size() > 5) { + foundCrashes.resize(5); + } + + // Copy selected files + double total = foundCrashes.size(); + for (size_t i = 0; i < foundCrashes.size(); ++i) { + const auto& entry = foundCrashes[i]; + std::string fileName = entry.path.filename().string(); + reportSubProgress("Collecting crash info: " + fileName, (double) i / total); + std::string destPath = std::filesystem::path(tempDir) / ("crash_" + fileName); + if (copyWithSizeLimit(entry.path.string(), destPath)) { + crashFiles.push_back(destPath); + } + } + + // Plugin custom crash dumps under plugin_config//crash/*.dmp + std::vector pluginCrashDirs = { + homeDir + "/Library/Application Support/obs-studio/plugin_config/17live/crash", + homeDir + "/Library/Application Support/obs-studio/plugin_config/obs-17live/crash"}; + for (const auto& dir : pluginCrashDirs) { + if (!std::filesystem::exists(dir)) + continue; + try { + for (const auto& entry : std::filesystem::directory_iterator(dir)) { + if (!entry.is_regular_file()) + continue; + if (entry.path().extension() == ".dmp") { + std::string destPath = std::filesystem::path(tempDir) / + ("crash_" + entry.path().filename().string()); + if (copyWithSizeLimit(entry.path().string(), destPath)) { + crashFiles.push_back(destPath); + } + } + } + } catch (const std::exception& e) { + setLastError(std::string("Error reading plugin crash dumps: ") + e.what()); + } + } + + return crashFiles; + } + + std::vector DiagnosticsCollectorMacOS::collectConfigSnapshot() { + std::vector configFiles; + std::string homeDir = getHomeDirectory(); + + std::string obsConfigDir = homeDir + "/Library/Application Support/obs-studio"; + // Use correct plugin config path defined by OneSevenLiveConfigManager: ~/.17Live + std::string pluginConfigDir = homeDir + "/.17Live"; + + std::string tempDir = generateTempDirectory(); + + if (std::filesystem::exists(obsConfigDir)) { + auto globalIni = obsConfigDir + "/global.ini"; + if (std::filesystem::exists(globalIni)) { + std::string destPath = std::filesystem::path(tempDir) / "obs_global.ini"; + if (copyFile(globalIni, destPath)) { + configFiles.push_back(destPath); + } + } + + auto basicIni = obsConfigDir + "/basic.ini"; + if (std::filesystem::exists(basicIni)) { + std::string destPath = std::filesystem::path(tempDir) / "obs_basic.ini"; + if (copyFile(basicIni, destPath)) { + configFiles.push_back(destPath); + } + } + } + + if (std::filesystem::exists(pluginConfigDir)) { + // Recursively copy all files under ~/.17Live to staging temp with preserved + // structure + std::vector filesToCopy; + try { + for (auto const& entry : + std::filesystem::recursive_directory_iterator(pluginConfigDir)) { + if (entry.is_regular_file()) { + filesToCopy.push_back(entry.path()); + } + } + } catch (...) { + } + + double total = filesToCopy.size(); + for (size_t i = 0; i < filesToCopy.size(); ++i) { + const auto& srcPath = filesToCopy[i]; + std::filesystem::path rel = std::filesystem::relative(srcPath, pluginConfigDir); + std::filesystem::path destPath = + std::filesystem::path(tempDir) / "plugin_config" / rel; + + reportSubProgress("Collecting config: " + srcPath.filename().string(), + (double) i / total); + + if (copyFile(srcPath.string(), destPath.string())) { + configFiles.push_back(destPath.string()); + } + } + } + + return configFiles; + } + + std::vector DiagnosticsCollectorMacOS::collectNetworkRequests() { + std::vector requestFiles; + + std::string tempDir = generateTempDirectory(); + std::string requestsPath = std::filesystem::path(tempDir) / "network_requests.txt"; + + std::stringstream ss; + ss << "Network Request Log (Sanitized)" << std::endl; + ss << "Generated on: " + << std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count() + << std::endl; + ss << "Note: Sensitive information has been filtered out" << std::endl; + ss << "========================================" << std::endl; + + if (writeToFile(requestsPath, ss.str())) { + requestFiles.push_back(requestsPath); + } + + return requestFiles; + } + + bool DiagnosticsCollectorMacOS::createZipArchive(const std::string& outputPath, + const std::vector& files) { + if (files.empty()) { + return false; + } + + // Create a staging directory where files are organized into category subdirectories + std::string stagingDir = generateTempDirectory(); + if (stagingDir.empty()) { + setLastError("Failed to create staging directory"); + return false; + } + + // Helper to determine category folder name based on filename pattern + auto determineCategory = [](const std::string& path) -> std::string { + std::string name = std::filesystem::path(path).filename().string(); + // OBS logs + if (name.rfind("obs_", 0) == 0 && name.find(".txt") != std::string::npos) { + return "obs_logs"; + } + // Plugin logs + if (name.rfind("plugin_", 0) == 0 && name.find(".log") != std::string::npos) { + return "plugin_logs"; + } + // Crash information + if (name.rfind("crash_", 0) == 0 || name.rfind("diag_", 0) == 0) { + return "crash_reports"; + } + // Configuration snapshot + if (name == "obs_global.ini" || name == "obs_basic.ini") { + return "Configuration snapshot"; + } + if (name.rfind("plugin_", 0) == 0 && name.find(".json") != std::string::npos) { + return "Configuration snapshot"; + } + // Any files under plugin_config directory should be treated as configuration + // snapshot + if (path.find("plugin_config") != std::string::npos) { + return "Configuration snapshot"; + } + // System information goes to root + if (name == "systeminfo.txt") { + return "ROOT"; + } + // Network requests + if (name == "network_requests.txt") { + return "Network requests"; + } + // Fallback + return "Misc"; + }; + + std::vector collectedFiles; + std::vector indexLines; + + try { + double total = files.size(); + for (size_t i = 0; i < files.size(); ++i) { + const auto& file = files[i]; + if (!std::filesystem::exists(file)) { + continue; + } + + std::string relativePath; + std::string category = determineCategory(file); + + std::filesystem::path destPath; + std::filesystem::path fileName = std::filesystem::path(file).filename(); + + reportSubProgress("Archiving: " + fileName.string(), (double) i / total); + + if (category == "ROOT") { + destPath = std::filesystem::path(stagingDir) / fileName; + relativePath = fileName.string(); + std::filesystem::create_directories(std::filesystem::path(stagingDir)); + } else { + std::filesystem::path categoryDir = + std::filesystem::path(stagingDir) / category; + std::filesystem::create_directories(categoryDir); + destPath = categoryDir / fileName; + relativePath = (std::filesystem::path(category) / fileName).string(); + } + + // Check file size limit (2MB) for crash reports + bool isLargeCrash = false; + if (category == "crash_reports") { + try { + auto fileSize = std::filesystem::file_size(file); + if (fileSize > 2 * 1024 * 1024) { // 2MB + isLargeCrash = true; + } + } catch (...) { + } + } + + if (isLargeCrash) { + indexLines.push_back(relativePath + " (文件过大,未采集)"); + } else { + try { + std::filesystem::copy_file( + file, destPath, std::filesystem::copy_options::overwrite_existing); + collectedFiles.push_back( + file); // Keep track of what we actually copied + indexLines.push_back(relativePath); + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy file: ") + e.what()); + indexLines.push_back(relativePath + " (Copy failed: " + e.what() + ")"); + } + } + } + + // Generate index.txt + try { + std::filesystem::path indexPath = + std::filesystem::path(stagingDir) / "index.txt"; + std::ofstream indexFile(indexPath); + if (indexFile.is_open()) { + indexFile << "Diagnostics Package Content Index\n"; + indexFile << "Generated on: " << executeCommand("date") << "\n"; + indexFile << "========================================\n\n"; + for (const auto& line : indexLines) { + indexFile << line << "\n"; + } + indexFile.close(); + } + } catch (...) { + // Ignore index generation errors + } + + } catch (const std::exception& e) { + setLastError(std::string("Failed to prepare staging files: ") + e.what()); + return false; + } + + // Zip from the parent of the staging directory so the ZIP contains + // a top-level diagnostics folder with categorized subdirectories + std::filesystem::path stagingPath(stagingDir); + std::string parentDir = stagingPath.parent_path().string(); + std::string baseName = stagingPath.filename().string(); + std::string zipCommand = + "cd \"" + parentDir + "\" && zip -r \"" + outputPath + "\" \"" + baseName + "\""; + std::string result = executeCommand(zipCommand); + (void) result; // Suppress unused variable warning + + return std::filesystem::exists(outputPath) && + std::filesystem::file_size(outputPath) > 0; + } + + std::string DiagnosticsCollectorMacOS::executeCommand(const std::string& command) const { + std::array buffer; + std::string result; + + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) { + return ""; + } + + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + result += buffer.data(); + } + + pclose(pipe); + return result; + } + + std::string DiagnosticsCollectorMacOS::getHomeDirectory() const { + const char* home = getenv("HOME"); + return home ? std::string(home) : ""; + } + + std::string DiagnosticsCollectorMacOS::getOBSLogDirectory() const { + return getHomeDirectory() + "/Library/Application Support/obs-studio/logs"; + } + + std::string DiagnosticsCollectorMacOS::getPluginLogDirectory() const { + // Prefer OBS plugin_config path; fallback to legacy ~/.17Live/logs + std::string home = getHomeDirectory(); + std::string primary = + home + "/Library/Application Support/obs-studio/plugin_config/17live/logs"; + if (std::filesystem::exists(primary)) + return primary; + std::string alt = + home + "/Library/Application Support/obs-studio/plugin_config/obs-17live/logs"; + if (std::filesystem::exists(alt)) + return alt; + return home + "/.17Live/logs"; + } + + std::string DiagnosticsCollectorMacOS::getCrashReportsDirectory() const { + return getHomeDirectory() + "/Library/Logs/DiagnosticReports"; + } + + std::vector DiagnosticsCollectorMacOS::getFilesInDirectory( + const std::string& directory, const std::string& pattern) { + std::vector files; + + if (!std::filesystem::exists(directory)) { + return files; + } + + try { + for (const auto& entry : std::filesystem::directory_iterator(directory)) { + if (entry.is_regular_file()) { + std::string filename = entry.path().filename().string(); + + if (pattern == "*.log" && filename.find(".log") != std::string::npos) { + files.push_back(entry.path().string()); + } else if (pattern == "*.crash" && + filename.find(".crash") != std::string::npos) { + files.push_back(entry.path().string()); + } else if (pattern == "*.diag" && + filename.find(".diag") != std::string::npos) { + files.push_back(entry.path().string()); + } else if (pattern == "*.json" && + filename.find(".json") != std::string::npos) { + files.push_back(entry.path().string()); + } else if (pattern == "*.txt" && + filename.find(".txt") != std::string::npos) { + files.push_back(entry.path().string()); + } + } + } + } catch (const std::exception& e) { + setLastError(std::string("Error reading directory: ") + e.what()); + } + + return files; + } + + bool DiagnosticsCollectorMacOS::copyFile(const std::string& source, + const std::string& destination) { + try { + std::filesystem::create_directories( + std::filesystem::path(destination).parent_path()); + std::filesystem::copy_file(source, destination, + std::filesystem::copy_options::overwrite_existing); + return true; + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy file: ") + e.what()); + return false; + } + } + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorMacOS.hpp b/src/diag/DiagnosticsCollectorMacOS.hpp new file mode 100644 index 0000000..9ce0114 --- /dev/null +++ b/src/diag/DiagnosticsCollectorMacOS.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include "DiagnosticsCollectorBase.hpp" + +namespace seventeen { + namespace diag { + + class DiagnosticsCollectorMacOS : public DiagnosticsCollectorBase { + public: + DiagnosticsCollectorMacOS(); + ~DiagnosticsCollectorMacOS() override = default; + + bool isSupported() const override { + return true; + } + + protected: + std::string getPlatformName() const override { + return "macOS"; + } + + std::string getSystemInfoImpl() const override; + std::vector collectOBSLogs() override; + std::vector collectPluginLogs() override; + std::vector collectNetworkLogs() override; + std::vector collectCrashInfo() override; + std::vector collectConfigSnapshot() override; + std::vector collectNetworkRequests() override; + bool createZipArchive(const std::string& outputPath, + const std::vector& files) override; + + private: + std::string executeCommand(const std::string& command) const; + std::string getHomeDirectory() const; + std::string getOBSLogDirectory() const; + std::string getPluginLogDirectory() const; + std::string getCrashReportsDirectory() const; + std::vector getFilesInDirectory(const std::string& directory, + const std::string& pattern); + bool copyFile(const std::string& source, const std::string& destination); + }; + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorWindows.cpp b/src/diag/DiagnosticsCollectorWindows.cpp new file mode 100644 index 0000000..d20602c --- /dev/null +++ b/src/diag/DiagnosticsCollectorWindows.cpp @@ -0,0 +1,513 @@ +#include "DiagnosticsCollectorWindows.hpp" + +#include + +#include +#include + +namespace seventeen { + namespace diag { + + DiagnosticsCollectorWindows::DiagnosticsCollectorWindows() {} + + bool DiagnosticsCollectorWindows::isSupported() const { +#ifdef _WIN32 + return true; +#else + return false; +#endif + } + + std::string DiagnosticsCollectorWindows::getSystemInfoImpl() const { + std::stringstream ss; + + ss << "Windows Version: " + << executePowerShellCommand("(Get-CimInstance Win32_OperatingSystem).Caption") + << std::endl; + ss << "Build Number: " + << executePowerShellCommand("(Get-CimInstance Win32_OperatingSystem).BuildNumber") + << std::endl; + ss << "Architecture: " + << executePowerShellCommand("(Get-CimInstance Win32_OperatingSystem).OSArchitecture") + << std::endl; + + ss << "\nHardware Information:" << std::endl; + ss << "CPU: " << executePowerShellCommand("(Get-CimInstance Win32_Processor).Name") + << std::endl; + ss << "Memory: " + << executePowerShellCommand( + "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory") + << " bytes" << std::endl; + ss << "Manufacturer: " + << executePowerShellCommand("(Get-CimInstance Win32_ComputerSystem).Manufacturer") + << std::endl; + ss << "Model: " + << executePowerShellCommand("(Get-CimInstance Win32_ComputerSystem).Model") + << std::endl; + + ss << "\nGraphics Information:" << std::endl; + ss << executePowerShellCommand( + "Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM, " + "DriverVersion | Format-Table -AutoSize") + << std::endl; + + ss << "\nDisk Information:" << std::endl; + ss << executePowerShellCommand( + "Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID, Size, " + "FreeSpace, FileSystem | Format-Table -AutoSize") + << std::endl; + + return ss.str(); + } + + std::vector DiagnosticsCollectorWindows::collectOBSLogs() { + std::vector logFiles; + std::string obsLogDir = getOBSLogDirectory(); + + if (std::filesystem::exists(obsLogDir)) { + auto files = getFilesInDirectory(obsLogDir, ".txt"); + std::sort(files.begin(), files.end(), + [](const std::string& a, const std::string& b) { + return std::filesystem::last_write_time(a) > + std::filesystem::last_write_time(b); + }); + std::string tempDir = generateTempDirectory(); + + for (const auto& file : files) { + std::string fileName = std::filesystem::path(file).filename().string(); + std::string destPath = + (std::filesystem::path(tempDir) / ("obs_" + fileName)).string(); + if (copyWithSizeLimit(file, destPath)) { + logFiles.push_back(destPath); + } + } + } + + return logFiles; + } + + std::vector DiagnosticsCollectorWindows::collectPluginLogs() { + std::vector logFiles; + std::string pluginLogDir = getPluginLogDirectory(); + + if (std::filesystem::exists(pluginLogDir)) { + auto files = getFilesInDirectory(pluginLogDir, ".log"); + std::sort(files.begin(), files.end(), + [](const std::string& a, const std::string& b) { + return std::filesystem::last_write_time(a) > + std::filesystem::last_write_time(b); + }); + if (files.size() > 5) + files.resize(5); + std::string tempDir = generateTempDirectory(); + + for (const auto& file : files) { + std::string fileName = std::filesystem::path(file).filename().string(); + std::string destPath = + (std::filesystem::path(tempDir) / ("plugin_" + fileName)).string(); + if (copyWithSizeLimit(file, destPath)) { + logFiles.push_back(destPath); + } + } + } + + return logFiles; + } + + std::vector DiagnosticsCollectorWindows::collectNetworkLogs() { + std::vector networkLogs; + + std::string tempDir = generateTempDirectory(); + std::string networkInfoPath = + (std::filesystem::path(tempDir) / "network_info.txt").string(); + + std::stringstream ss; + ss << "Network Interfaces:" << std::endl; + ss << executePowerShellCommand( + "Get-CimInstance Win32_NetworkAdapterConfiguration | Where-Object " + "{$_.IPEnabled -eq $true} | Select-Object Description, IPAddress, MACAddress " + "| Format-Table -AutoSize") + << std::endl; + ss << "\nNetwork Statistics:" << std::endl; + ss << executePowerShellCommand( + "Get-CimInstance Win32_PerfRawData_Tcpip_NetworkInterface | Select-Object " + "Name, BytesReceivedPersec, BytesSentPersec | Format-Table -AutoSize") + << std::endl; + + if (writeToFile(networkInfoPath, ss.str())) { + networkLogs.push_back(networkInfoPath); + } + + return networkLogs; + } + + std::vector DiagnosticsCollectorWindows::collectCrashInfo() { + std::vector crashFiles; + std::string crashDir = getCrashDumpDirectory(); + + if (std::filesystem::exists(crashDir)) { + auto files = getFilesInDirectory(crashDir, ".dmp"); + // Filter to obs*.dmp only + files.erase(std::remove_if(files.begin(), files.end(), + [](const std::string& path) { + std::string name = + std::filesystem::path(path).filename().string(); + return name.rfind("obs", 0) != + 0; // keep names starting with 'obs' + }), + files.end()); + std::sort(files.begin(), files.end(), + [](const std::string& a, const std::string& b) { + return std::filesystem::last_write_time(a) > + std::filesystem::last_write_time(b); + }); + if (files.size() > 3) + files.resize(3); + std::string tempDir = generateTempDirectory(); + + for (const auto& file : files) { + std::string fileName = std::filesystem::path(file).filename().string(); + std::string destPath = + (std::filesystem::path(tempDir) / ("crash_" + fileName)).string(); + if (copyWithSizeLimit(file, destPath)) { + crashFiles.push_back(destPath); + } + } + } + + std::string tempDir = generateTempDirectory(); + std::string eventLogPath = + (std::filesystem::path(tempDir) / "application_events.txt").string(); + + std::stringstream ss; + ss << "Application Error Events:" << std::endl; + ss << executePowerShellCommand( + "Get-EventLog -LogName Application -EntryType Error -Newest 50 | " + "Select-Object TimeGenerated, Source, Message | Format-Table -AutoSize") + << std::endl; + + if (writeToFile(eventLogPath, ss.str())) { + crashFiles.push_back(eventLogPath); + } + + return crashFiles; + } + + std::vector DiagnosticsCollectorWindows::collectConfigSnapshot() { + std::vector configFiles; + std::string appDataPath = getAppDataPath(); + + std::string obsConfigDir = appDataPath + "\\obs-studio"; + std::string pluginConfigDir = appDataPath + "\\17live-obs-plugin"; + + std::string tempDir = generateTempDirectory(); + + if (std::filesystem::exists(obsConfigDir)) { + auto globalIni = obsConfigDir + "\\global.ini"; + if (std::filesystem::exists(globalIni)) { + std::string destPath = + (std::filesystem::path(tempDir) / "obs_global.ini").string(); + if (copyFile(globalIni, destPath)) { + configFiles.push_back(destPath); + } + } + + auto basicIni = obsConfigDir + "\\basic.ini"; + if (std::filesystem::exists(basicIni)) { + std::string destPath = + (std::filesystem::path(tempDir) / "obs_basic.ini").string(); + if (copyFile(basicIni, destPath)) { + configFiles.push_back(destPath); + } + } + } + + if (std::filesystem::exists(pluginConfigDir)) { + auto pluginFiles = getFilesInDirectory(pluginConfigDir, ".json"); + for (const auto& file : pluginFiles) { + std::string fileName = std::filesystem::path(file).filename().string(); + std::string destPath = + (std::filesystem::path(tempDir) / ("plugin_" + fileName)).string(); + + if (copyFile(file, destPath)) { + configFiles.push_back(destPath); + } + } + } + + return configFiles; + } + + std::vector DiagnosticsCollectorWindows::collectNetworkRequests() { + std::vector requestFiles; + + std::string tempDir = generateTempDirectory(); + std::string requestsPath = + (std::filesystem::path(tempDir) / "network_requests.txt").string(); + + std::stringstream ss; + ss << "Network Request Log (Sanitized)" << std::endl; + ss << "Generated on: " + << std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count() + << std::endl; + ss << "Note: Sensitive information has been filtered out" << std::endl; + ss << "========================================" << std::endl; + + if (writeToFile(requestsPath, ss.str())) { + requestFiles.push_back(requestsPath); + } + + return requestFiles; + } + + bool DiagnosticsCollectorWindows::createZipArchive(const std::string& outputPath, + const std::vector& files) { + if (files.empty()) { + return false; + } + + // Create staging directory and categorize similar to macOS + std::string stagingDir = generateTempDirectory(); + if (stagingDir.empty()) { + setLastError("Failed to create staging directory"); + return false; + } + + auto determineCategory = [](const std::string& path) -> std::string { + std::string name = std::filesystem::path(path).filename().string(); + if (name.rfind("obs_", 0) == 0 && name.find(".txt") != std::string::npos) { + return "obs_logs"; + } + if (name.rfind("plugin_", 0) == 0 && name.find(".log") != std::string::npos) { + return "plugin_logs"; + } + if (name.rfind("crash_", 0) == 0) { + return "crash_reports"; + } + if (name == "systeminfo.txt") { + return "ROOT"; + } + if (name == "network_requests.txt") { + return "Network requests"; + } + return "Misc"; + }; + + std::vector collectedFiles; + std::vector indexLines; + + try { + for (const auto& file : files) { + if (!std::filesystem::exists(file)) + continue; + + std::string relativePath; + std::string category = determineCategory(file); + + std::filesystem::path destPath; + std::filesystem::path fileName = std::filesystem::path(file).filename(); + + if (category == "ROOT") { + destPath = std::filesystem::path(stagingDir) / fileName; + relativePath = fileName.string(); + std::filesystem::create_directories(std::filesystem::path(stagingDir)); + } else { + std::filesystem::path categoryDir = + std::filesystem::path(stagingDir) / category; + std::filesystem::create_directories(categoryDir); + destPath = categoryDir / fileName; + relativePath = (std::filesystem::path(category) / fileName).string(); + } + + // Check file size limit (2MB) for crash reports + bool isLargeCrash = false; + if (category == "crash_reports") { + try { + auto fileSize = std::filesystem::file_size(file); + if (fileSize > 2 * 1024 * 1024) { // 2MB + isLargeCrash = true; + } + } catch (...) { + } + } + + if (isLargeCrash) { + indexLines.push_back(relativePath + " (文件过大,未采集)"); + } else { + try { + std::filesystem::copy_file( + file, destPath, std::filesystem::copy_options::overwrite_existing); + collectedFiles.push_back(file); + indexLines.push_back(relativePath); + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy file: ") + e.what()); + indexLines.push_back(relativePath + " (Copy failed: " + e.what() + ")"); + } + } + } + + // Generate index.txt + try { + std::filesystem::path indexPath = + std::filesystem::path(stagingDir) / "index.txt"; + std::ofstream indexFile(indexPath); + if (indexFile.is_open()) { + indexFile << "Diagnostics Package Content Index\n"; + indexFile << "Generated on: " << executePowerShellCommand("Get-Date") + << "\n"; + indexFile << "========================================\n\n"; + for (const auto& line : indexLines) { + indexFile << line << "\n"; + } + indexFile.close(); + } + } catch (...) { + // Ignore index generation errors + } + + } catch (const std::exception& e) { + setLastError(std::string("Failed to prepare staging files: ") + e.what()); + return false; + } + + std::string zipCommand = "Compress-Archive -LiteralPath '" + stagingDir + + "' -DestinationPath '" + outputPath + "' -Force"; + std::string result = executePowerShellCommand(zipCommand); + if (std::filesystem::exists(outputPath) && + std::filesystem::is_regular_file(outputPath) && + std::filesystem::file_size(outputPath) > 0) { + return true; + } + + auto copyDir = [&](const std::string& src, const std::string& dst) -> bool { + try { + std::filesystem::create_directories(dst); + for (const auto& entry : std::filesystem::recursive_directory_iterator(src)) { + const auto& p = entry.path(); + auto rel = std::filesystem::relative(p, src); + auto dest = std::filesystem::path(dst) / rel; + if (entry.is_directory()) { + std::filesystem::create_directories(dest); + } else if (entry.is_regular_file()) { + std::filesystem::create_directories(dest.parent_path()); + std::filesystem::copy_file( + p, dest, std::filesystem::copy_options::overwrite_existing); + } + } + return true; + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy directory: ") + e.what()); + return false; + } + }; + + if (copyDir(stagingDir, outputPath)) { + return true; + } + + setLastError("Failed to create archive and fallback folder"); + return false; + } + + std::string DiagnosticsCollectorWindows::executePowerShellCommand( + const std::string& command) const { + auto run = [&](const char* exe) -> std::string { + std::string full = std::string(exe) + + " -NoProfile -NonInteractive -WindowStyle Hidden -NoLogo " + "-Command \"" + + command + "\" 2>&1"; + FILE* pipe = _popen(full.c_str(), "r"); + if (!pipe) { + return std::string(); + } + char buffer[512]; + std::string out; + while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + out += buffer; + } + _pclose(pipe); + return out; + }; + + std::string r = run("powershell"); + if (r.empty()) { + r = run("pwsh"); + } + return r; + } + + std::string DiagnosticsCollectorWindows::getAppDataPath() const { + char* appData = nullptr; + size_t len = 0; + + if (_dupenv_s(&appData, &len, "APPDATA") == 0 && appData != nullptr) { + std::string result(appData); + free(appData); + return result; + } + + return ""; + } + + std::string DiagnosticsCollectorWindows::getOBSLogDirectory() const { + return getAppDataPath() + "\\obs-studio\\logs"; + } + + std::string DiagnosticsCollectorWindows::getPluginLogDirectory() const { + std::string path = getAppDataPath() + "\\obs-studio\\plugin_config\\17live\\logs"; + if (std::filesystem::exists(path)) + return path; + path = getAppDataPath() + "\\obs-studio\\plugin_config\\obs-17live\\logs"; + return path; + } + + std::string DiagnosticsCollectorWindows::getCrashDumpDirectory() const { + char* localAppData = nullptr; + size_t len = 0; + if (_dupenv_s(&localAppData, &len, "LOCALAPPDATA") == 0 && localAppData != nullptr) { + std::string result(localAppData); + free(localAppData); + return result + "\\CrashDumps"; + } + return std::string(); + } + + std::vector DiagnosticsCollectorWindows::getFilesInDirectory( + const std::string& directory, const std::string& extension) { + std::vector files; + + if (!std::filesystem::exists(directory)) { + return files; + } + + try { + for (const auto& entry : std::filesystem::directory_iterator(directory)) { + if (entry.is_regular_file() && entry.path().extension() == extension) { + files.push_back(entry.path().string()); + } + } + } catch (const std::exception& e) { + setLastError(std::string("Error reading directory: ") + e.what()); + } + + return files; + } + + bool DiagnosticsCollectorWindows::copyFile(const std::string& source, + const std::string& destination) { + try { + std::filesystem::create_directories( + std::filesystem::path(destination).parent_path()); + std::filesystem::copy_file(source, destination, + std::filesystem::copy_options::overwrite_existing); + return true; + } catch (const std::exception& e) { + setLastError(std::string("Failed to copy file: ") + e.what()); + return false; + } + } + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/DiagnosticsCollectorWindows.hpp b/src/diag/DiagnosticsCollectorWindows.hpp new file mode 100644 index 0000000..fab36ec --- /dev/null +++ b/src/diag/DiagnosticsCollectorWindows.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "DiagnosticsCollectorBase.hpp" + +namespace seventeen { + namespace diag { + + class DiagnosticsCollectorWindows : public DiagnosticsCollectorBase { + public: + DiagnosticsCollectorWindows(); + ~DiagnosticsCollectorWindows() override = default; + + bool isSupported() const override; + + protected: + std::string getPlatformName() const override { + return "Windows"; + } + + std::string getSystemInfoImpl() const override; + std::vector collectOBSLogs() override; + std::vector collectPluginLogs() override; + std::vector collectNetworkLogs() override; + std::vector collectCrashInfo() override; + std::vector collectConfigSnapshot() override; + std::vector collectNetworkRequests() override; + bool createZipArchive(const std::string& outputPath, + const std::vector& files) override; + + private: + std::string executePowerShellCommand(const std::string& command) const; + std::string getAppDataPath() const; + std::string getOBSLogDirectory() const; + std::string getPluginLogDirectory() const; + std::string getCrashDumpDirectory() const; + std::vector getFilesInDirectory(const std::string& directory, + const std::string& extension); + bool copyFile(const std::string& source, const std::string& destination); + std::string getWMIInfo(const std::string& wmiClass, const std::string& property); + }; + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/IDiagnosticsCollector.hpp b/src/diag/IDiagnosticsCollector.hpp new file mode 100644 index 0000000..5d200bf --- /dev/null +++ b/src/diag/IDiagnosticsCollector.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +namespace seventeen { + namespace diag { + + enum class DiagnosticCategory { + OBS_LOGS, + PLUGIN_LOGS, + NETWORK_LOGS, + SYSTEM_INFO, + CRASH_INFO, + CONFIG_SNAPSHOT, + NETWORK_REQUESTS + }; + + enum class CollectStatus { SUCCESS, ERROR, CANCELLED, PERMISSION_DENIED }; + + struct CollectResult { + CollectStatus status = CollectStatus::ERROR; + std::string message; + std::string outputPath; + std::vector collectedFiles; + }; + + struct DiagnosticConfig { + std::vector categories; + std::string outputDirectory; + bool enablePrivacyFilter = false; + bool includeSensitiveData = false; + std::vector customPaths; + }; + + class IDiagnosticsCollector { + public: + virtual ~IDiagnosticsCollector() = default; + + virtual CollectResult collect(const DiagnosticConfig& config) = 0; + + virtual std::vector getAvailableCategories() const = 0; + + virtual std::string getSystemInfo() const = 0; + + virtual bool isSupported() const = 0; + + virtual std::string getLastError() const = 0; + + using ProgressCallback = std::function; + virtual void setProgressCallback(ProgressCallback callback) = 0; + }; + + std::unique_ptr createDiagnosticsCollector(); + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/PrivacyFilter.cpp b/src/diag/PrivacyFilter.cpp new file mode 100644 index 0000000..d2e1683 --- /dev/null +++ b/src/diag/PrivacyFilter.cpp @@ -0,0 +1,152 @@ +#include "PrivacyFilter.hpp" + +#include +#include + +namespace seventeen { + namespace diag { + + PrivacyFilter::PrivacyFilter() + : m_filterLevel(FilterLevel::MODERATE), m_maskCharacter('*') { + initializeDefaultPatterns(); + } + + std::string PrivacyFilter::filterSensitiveData(const std::string& input) { + std::string result = input; + + for (const auto& pattern : m_patterns) { + if (!pattern.enabled || !shouldApplyPattern(pattern)) { + continue; + } + + try { + result = std::regex_replace(result, pattern.pattern, pattern.replacement); + } catch (const std::regex_error&) { + // Skip invalid patterns + continue; + } + } + + return result; + } + + void PrivacyFilter::addCustomPattern(const std::string& pattern, + const std::string& replacement) { + try { + m_patterns.push_back( + {std::regex(pattern, std::regex_constants::icase), replacement, true}); + } catch (const std::regex_error&) { + // Invalid regex pattern, ignore + } + } + + void PrivacyFilter::initializeDefaultPatterns() { + // API Keys and Tokens + m_patterns.push_back({std::regex(R"(api[_-]?key["\s]*[:=]["\s]*([a-zA-Z0-9_\-]{20,}))", + std::regex_constants::icase), + "api_key=***REDACTED***", true}); + + m_patterns.push_back( + {std::regex(R"(bearer["\s]+([a-zA-Z0-9_\-\.]{20,}))", std::regex_constants::icase), + "Bearer ***REDACTED***", true}); + + m_patterns.push_back({std::regex(R"(authorization["\s]*:["\s]*([a-zA-Z0-9_\-\s]{10,}))", + std::regex_constants::icase), + "Authorization: ***REDACTED***", true}); + + // Passwords + m_patterns.push_back({std::regex(R"(password["\s]*[:=]["\s]*([^"\s\n]{3,}))", + std::regex_constants::icase), + "password=***REDACTED***", true}); + + m_patterns.push_back( + {std::regex(R"(pwd["\s]*[:=]["\s]*([^"\s\n]{3,}))", std::regex_constants::icase), + "pwd=***REDACTED***", true}); + + // Personal Information (Moderate and Strict levels) + m_patterns.push_back({ + std::regex(R"(\b\d{3}-\d{2}-\d{4}\b)", std::regex_constants::icase), + "***SSN_REDACTED***", + false // Disabled by default, enabled in moderate/strict + }); + + m_patterns.push_back( + {std::regex(R"(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b)", + std::regex_constants::icase), + "***EMAIL_REDACTED***", false}); + + // IP Addresses (Strict level only) + m_patterns.push_back( + {std::regex(R"(\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b)", std::regex_constants::icase), + "***IP_REDACTED***", false}); + + // URLs with credentials + m_patterns.push_back( + {std::regex( + R"((https?://)[a-zA-Z0-9._%+-]+:[^@\s]+@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))", + std::regex_constants::icase), + "$1***CREDENTIALS_REDACTED***@$2", true}); + + // Session IDs and similar + m_patterns.push_back( + {std::regex(R"(session[_-]?id["\s]*[:=]["\s]*([a-zA-Z0-9_\-]{10,}))", + std::regex_constants::icase), + "session_id=***REDACTED***", true}); + + // Private keys + m_patterns.push_back( + {std::regex( + R"(-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+ PRIVATE KEY-----)", + std::regex_constants::icase), + "-----BEGIN PRIVATE KEY-----\n***REDACTED***\n-----END PRIVATE KEY-----", true}); + + // Database connection strings + m_patterns.push_back( + {std::regex(R"(Server=[^;]+;Database=[^;]+;User [A-Za-z]+=[^;]+;Password=[^;]+;)", + std::regex_constants::icase), + "***CONNECTION_STRING_REDACTED***", true}); + + // File paths (user directories) + m_patterns.push_back( + {std::regex(R"([A-Za-z]:\\Users\\[^\\]+\\)", std::regex_constants::icase), + "C:\\Users\\***USERNAME_REDACTED***\\", false}); + + m_patterns.push_back({std::regex(R"(/Users/[^/]+/)", std::regex_constants::icase), + "/Users/***USERNAME_REDACTED***/", false}); + } + + std::string PrivacyFilter::maskString(const std::string& input, size_t start, + size_t length) { + if (start >= input.length() || length == 0) { + return input; + } + + size_t end = std::min(start + length, input.length()); + std::string result = input; + + for (size_t i = start; i < end; ++i) { + result[i] = m_maskCharacter; + } + + return result; + } + + bool PrivacyFilter::shouldApplyPattern(const FilterPattern& pattern) const { + switch (m_filterLevel) { + case FilterLevel::STRICT: + return true; // Apply all patterns + case FilterLevel::MODERATE: + // Apply patterns that are enabled by default or marked for moderate + return pattern.enabled || pattern.replacement.find("EMAIL") != std::string::npos || + pattern.replacement.find("SSN") != std::string::npos; + case FilterLevel::MINIMAL: + // Only apply critical patterns (passwords, API keys, etc.) + return pattern.enabled && + (pattern.replacement.find("REDACTED") != std::string::npos || + pattern.replacement.find("password") != std::string::npos); + } + return false; + } + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/PrivacyFilter.hpp b/src/diag/PrivacyFilter.hpp new file mode 100644 index 0000000..7d9c23f --- /dev/null +++ b/src/diag/PrivacyFilter.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +namespace seventeen { + namespace diag { + + class PrivacyFilter { + public: + PrivacyFilter(); + ~PrivacyFilter() = default; + + std::string filterSensitiveData(const std::string& input); + + void addCustomPattern(const std::string& pattern, const std::string& replacement); + + void setMaskCharacter(char mask) { + m_maskCharacter = mask; + } + + enum class FilterLevel { + STRICT, // Remove all potentially sensitive data + MODERATE, // Remove obvious sensitive data + MINIMAL // Remove only critical data like passwords + }; + + void setFilterLevel(FilterLevel level) { + m_filterLevel = level; + } + + private: + struct FilterPattern { + std::regex pattern; + std::string replacement; + bool enabled; + }; + + std::vector m_patterns; + FilterLevel m_filterLevel = FilterLevel::MODERATE; + char m_maskCharacter = '*'; + + void initializeDefaultPatterns(); + std::string maskString(const std::string& input, size_t start, size_t length); + bool shouldApplyPattern(const FilterPattern& pattern) const; + }; + + } // namespace diag +} // namespace seventeen diff --git a/src/diag/ui/DiagnosticsDialog.cpp b/src/diag/ui/DiagnosticsDialog.cpp new file mode 100644 index 0000000..ee38d2e --- /dev/null +++ b/src/diag/ui/DiagnosticsDialog.cpp @@ -0,0 +1,346 @@ +#include "DiagnosticsDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../DiagnosticsCollectorFactory.hpp" +#include "../IDiagnosticsCollector.hpp" + +namespace seventeen { + namespace diag { + namespace ui { + + DiagnosticsDialog::DiagnosticsDialog(QWidget* parent) + : QDialog(parent), m_workerThread(nullptr) { + setupUI(); + + // Create worker thread + m_workerThread = new QThread(this); + m_worker = new DiagnosticsWorker(); + m_worker->moveToThread(m_workerThread); + + // Connect signals + connect(this, &DiagnosticsDialog::startCollection, m_worker, + &DiagnosticsWorker::performCollection); + connect(m_worker, &DiagnosticsWorker::collectionCompleted, this, + &DiagnosticsDialog::onCollectionCompleted); + connect(m_worker, &DiagnosticsWorker::progressUpdate, this, + &DiagnosticsDialog::onProgressUpdate); + connect(m_worker, &DiagnosticsWorker::error, this, + &DiagnosticsDialog::onCollectionError); + + connect(m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); + + m_workerThread->start(); + + // Set default output path + m_outputPath = getDefaultOutputPath(); + m_outputPathLabel->setText(m_outputPath); + + // Set default state + m_privacyFilterCheckBox->setChecked(true); + updateCategories(); + } + + DiagnosticsDialog::~DiagnosticsDialog() { + if (m_workerThread) { + // Stop thread and ensure queued deletes run + m_workerThread->quit(); + m_workerThread->wait(); + m_workerThread->deleteLater(); + } + // Worker will be deleted by deleteLater when the thread finishes + m_worker = nullptr; + } + + void DiagnosticsDialog::setupUI() { + setWindowTitle(obs_module_text("Diagnostics.Title")); + setModal(true); + resize(600, 500); + + auto* mainLayout = new QVBoxLayout(this); + + // Information label + auto* infoLabel = new QLabel(obs_module_text("Diagnostics.Description"), this); + infoLabel->setWordWrap(true); + infoLabel->setStyleSheet( + "QLabel { padding: 10px; background-color: #000000; color: white; " + "border-radius: 5px; }"); + mainLayout->addWidget(infoLabel); + + // Categories group + auto* categoriesGroup = + new QGroupBox(obs_module_text("Diagnostics.Categories.Title"), this); + auto* categoriesLayout = new QVBoxLayout(categoriesGroup); + + m_obsLogsCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.OBSLogs"), this); + m_pluginLogsCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.PluginLogs"), this); + m_networkLogsCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.NetworkLogs"), this); + m_systemInfoCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.SystemInfo"), this); + m_crashInfoCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.CrashInfo"), this); + m_configSnapshotCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.ConfigSnapshot"), this); + m_networkRequestsCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.NetworkRequests"), this); + m_privacyFilterCheckBox = + new QCheckBox(obs_module_text("Diagnostics.Categories.PrivacyFilter"), this); + + categoriesLayout->addWidget(m_obsLogsCheckBox); + categoriesLayout->addWidget(m_pluginLogsCheckBox); + categoriesLayout->addWidget(m_networkLogsCheckBox); + categoriesLayout->addWidget(m_systemInfoCheckBox); + categoriesLayout->addWidget(m_crashInfoCheckBox); + categoriesLayout->addWidget(m_configSnapshotCheckBox); + categoriesLayout->addWidget(m_networkRequestsCheckBox); + categoriesLayout->addWidget(m_privacyFilterCheckBox); + + // 默认启用且选中四个分类:OBS 日志、插件日志、崩溃信息、配置快照 + m_obsLogsCheckBox->setChecked(true); + m_pluginLogsCheckBox->setChecked(true); + m_crashInfoCheckBox->setChecked(true); + m_configSnapshotCheckBox->setChecked(true); + + // 隐藏暂不启用的分类 + m_networkLogsCheckBox->setVisible(false); + m_systemInfoCheckBox->setVisible(false); + m_networkRequestsCheckBox->setVisible(false); + + mainLayout->addWidget(categoriesGroup); + + // Output path selection + auto* outputLayout = new QHBoxLayout(); + auto* outputLabel = new QLabel(obs_module_text("Diagnostics.OutputPath"), this); + m_outputPathLabel = new QLabel(this); + m_browseButton = new QPushButton(obs_module_text("Diagnostics.Browse"), this); + + outputLayout->addWidget(outputLabel); + outputLayout->addWidget(m_outputPathLabel, 1); + outputLayout->addWidget(m_browseButton); + + mainLayout->addLayout(outputLayout); + + // Progress bar + m_progressBar = new QProgressBar(this); + m_progressBar->setVisible(false); + mainLayout->addWidget(m_progressBar); + + // Status text + m_statusTextEdit = new QTextEdit(this); + m_statusTextEdit->setReadOnly(true); + m_statusTextEdit->setMaximumHeight(100); + m_statusTextEdit->setPlainText(obs_module_text("Diagnostics.Status.Ready")); + mainLayout->addWidget(m_statusTextEdit); + + // Buttons + auto* buttonLayout = new QHBoxLayout(); + m_collectButton = new QPushButton(obs_module_text("Diagnostics.Collect"), this); + m_cancelButton = new QPushButton(obs_module_text("Diagnostics.Cancel"), this); + + buttonLayout->addStretch(); + buttonLayout->addWidget(m_collectButton); + buttonLayout->addWidget(m_cancelButton); + + mainLayout->addLayout(buttonLayout); + + // Connect button signals + connect(m_collectButton, &QPushButton::clicked, this, + &DiagnosticsDialog::onCollectClicked); + connect(m_cancelButton, &QPushButton::clicked, this, &QDialog::reject); + connect(m_browseButton, &QPushButton::clicked, this, + &DiagnosticsDialog::onBrowseClicked); + + // Connect checkbox changes + auto updateCategoriesSlot = [this]() { updateCategories(); }; + connect(m_obsLogsCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_pluginLogsCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_networkLogsCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_systemInfoCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_crashInfoCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_configSnapshotCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + connect(m_networkRequestsCheckBox, &QCheckBox::toggled, updateCategoriesSlot); + } + + void DiagnosticsDialog::onCollectClicked() { + showPrivacyDialog(); + + m_collectButton->setEnabled(false); + m_progressBar->setVisible(true); + m_progressBar->setValue(0); + m_statusTextEdit->clear(); // Clear previous status + + DiagnosticConfig config = getCurrentConfig(); + config.outputDirectory = m_outputPath.toStdString(); + + emit startCollection(config); + } + + void DiagnosticsDialog::onBrowseClicked() { + QString defaultPath = getDefaultOutputPath(); + QString fileName = + QFileDialog::getSaveFileName(this, obs_module_text("Diagnostics.SavePackage"), + defaultPath, "ZIP Files (*.zip)"); + + if (!fileName.isEmpty()) { + if (!fileName.endsWith(".zip", Qt::CaseInsensitive)) { + fileName += ".zip"; + } + m_outputPath = fileName; + m_outputPathLabel->setText(m_outputPath); + } + } + + void DiagnosticsDialog::onCollectionCompleted(const CollectResult& result) { + m_collectButton->setEnabled(true); + m_progressBar->setVisible(false); + + if (result.status == CollectStatus::SUCCESS) { + m_statusTextEdit->setPlainText( + QString("%1\n%2: %3\n%4: %5") + .arg(obs_module_text("Diagnostics.Status.Success")) + .arg(obs_module_text("Diagnostics.Status.Output")) + .arg(QString::fromStdString(result.outputPath)) + .arg(obs_module_text("Diagnostics.Status.FilesCollected")) + .arg(result.collectedFiles.size())); + + if (QMessageBox::question( + this, obs_module_text("Diagnostics.Status.OpenFolderTitle"), + obs_module_text("Diagnostics.Status.OpenFolderQuestion")) == + QMessageBox::Yes) { + QDesktopServices::openUrl(QUrl::fromLocalFile( + QFileInfo(QString::fromStdString(result.outputPath)).absolutePath())); + } + + accept(); + } else { + m_statusTextEdit->setPlainText( + QString("%1: %2") + .arg(obs_module_text("Diagnostics.Status.Failed")) + .arg(QString::fromStdString(result.message))); + + QMessageBox::critical( + this, obs_module_text("Diagnostics.Status.ErrorTitle"), + QString("%1: %2") + .arg(obs_module_text("Diagnostics.Status.ErrorMessage")) + .arg(QString::fromStdString(result.message))); + } + } + + void DiagnosticsDialog::onProgressUpdate(const QString& stage, double progress) { + m_statusTextEdit->append(stage); + // Scroll to bottom + QTextCursor c = m_statusTextEdit->textCursor(); + c.movePosition(QTextCursor::End); + m_statusTextEdit->setTextCursor(c); + + m_progressBar->setValue(static_cast(progress * 100)); + } + + void DiagnosticsDialog::onCollectionError(const QString& error) { + m_statusTextEdit->setPlainText( + QString("%1: %2").arg(obs_module_text("Diagnostics.Status.Error")).arg(error)); + m_collectButton->setEnabled(true); + m_progressBar->setVisible(false); + } + + void DiagnosticsDialog::updateCategories() { + // Update UI based on selections + bool hasSelection = + m_obsLogsCheckBox->isChecked() || m_pluginLogsCheckBox->isChecked() || + m_crashInfoCheckBox->isChecked() || m_configSnapshotCheckBox->isChecked(); + + m_collectButton->setEnabled(hasSelection); + } + + DiagnosticConfig DiagnosticsDialog::getCurrentConfig() const { + DiagnosticConfig config; + config.enablePrivacyFilter = m_privacyFilterCheckBox->isChecked(); + config.includeSensitiveData = false; + + if (m_obsLogsCheckBox->isChecked()) { + config.categories.push_back(DiagnosticCategory::OBS_LOGS); + } + if (m_pluginLogsCheckBox->isChecked()) { + config.categories.push_back(DiagnosticCategory::PLUGIN_LOGS); + } + // 暂不导出网络日志与系统信息 + if (m_crashInfoCheckBox->isChecked()) { + config.categories.push_back(DiagnosticCategory::CRASH_INFO); + } + if (m_configSnapshotCheckBox->isChecked()) { + config.categories.push_back(DiagnosticCategory::CONFIG_SNAPSHOT); + } + // 暂不导出网络请求 + + return config; + } + + QString DiagnosticsDialog::getDefaultOutputPath() const { + QString desktopPath = + QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); + QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + return desktopPath + "/17live_diagnostics_" + timestamp + ".zip"; + } + + void DiagnosticsDialog::showPrivacyDialog() { + if (!m_privacyFilterCheckBox->isChecked()) { + int result = QMessageBox::warning( + this, obs_module_text("Diagnostics.Privacy.WarningTitle"), + obs_module_text("Diagnostics.Privacy.WarningMessage"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (result == QMessageBox::No) { + m_privacyFilterCheckBox->setChecked(true); + } + } + } + + // DiagnosticsWorker implementation + + DiagnosticsWorker::DiagnosticsWorker(QObject* parent) + : QObject(parent), m_collector(createDiagnosticsCollector()) {} + + DiagnosticsWorker::~DiagnosticsWorker() = default; + + void DiagnosticsWorker::performCollection(const DiagnosticConfig& config) { + if (!m_collector) { + emit error(obs_module_text("Diagnostics.Status.CollectorFailed")); + return; + } + + m_collector->setProgressCallback([this](const std::string& stage, double progress) { + emit progressUpdate(QString::fromStdString(stage), progress); + }); + + CollectResult result = m_collector->collect(config); + + if (result.status == CollectStatus::ERROR) { + emit error(QString::fromStdString(result.message)); + } else { + emit collectionCompleted(result); + } + } + + } // namespace ui + } // namespace diag +} // namespace seventeen + +#include "moc_DiagnosticsDialog.cpp" diff --git a/src/diag/ui/DiagnosticsDialog.hpp b/src/diag/ui/DiagnosticsDialog.hpp new file mode 100644 index 0000000..a8da882 --- /dev/null +++ b/src/diag/ui/DiagnosticsDialog.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include + +#include "../IDiagnosticsCollector.hpp" + +QT_BEGIN_NAMESPACE +class QCheckBox; +class QProgressBar; +class QTextEdit; +class QPushButton; +class QLabel; +QT_END_NAMESPACE + +namespace seventeen { + namespace diag { + namespace ui { + + class DiagnosticsWorker; + + class DiagnosticsDialog : public QDialog { + Q_OBJECT + + public: + explicit DiagnosticsDialog(QWidget* parent = nullptr); + ~DiagnosticsDialog(); + + signals: + void startCollection(const DiagnosticConfig& config); + + private slots: + void onCollectClicked(); + void onBrowseClicked(); + void onCollectionCompleted(const CollectResult& result); + void onProgressUpdate(const QString& stage, double progress); + void onCollectionError(const QString& error); + + private: + void setupUI(); + void updateCategories(); + DiagnosticConfig getCurrentConfig() const; + QString getDefaultOutputPath() const; + void showPrivacyDialog(); + + // UI Elements + QCheckBox* m_obsLogsCheckBox = nullptr; + QCheckBox* m_pluginLogsCheckBox = nullptr; + QCheckBox* m_networkLogsCheckBox = nullptr; + QCheckBox* m_systemInfoCheckBox = nullptr; + QCheckBox* m_crashInfoCheckBox = nullptr; + QCheckBox* m_configSnapshotCheckBox = nullptr; + QCheckBox* m_networkRequestsCheckBox = nullptr; + QCheckBox* m_privacyFilterCheckBox = nullptr; + + QProgressBar* m_progressBar = nullptr; + QTextEdit* m_statusTextEdit = nullptr; + QPushButton* m_collectButton = nullptr; + QPushButton* m_cancelButton = nullptr; + QPushButton* m_browseButton = nullptr; + QLabel* m_outputPathLabel = nullptr; + + QString m_outputPath; + DiagnosticsWorker* m_worker = nullptr; + QThread* m_workerThread = nullptr; + }; + + class DiagnosticsWorker : public QObject { + Q_OBJECT + + public: + explicit DiagnosticsWorker(QObject* parent = nullptr); + ~DiagnosticsWorker(); + + public slots: + void performCollection(const DiagnosticConfig& config); + + signals: + void collectionCompleted(const CollectResult& result); + void progressUpdate(const QString& stage, double progress); + void error(const QString& message); + + private: + std::unique_ptr m_collector; + }; + + } // namespace ui + } // namespace diag +} // namespace seventeen diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index dd05fb5..46d5bae 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -21,23 +21,22 @@ with this program. If not, see #include #include +#include #include #include +#include +#include #include #include #include +#include +#include #include #include -#if defined(__APPLE__) -#include "include/wrapper/cef_library_loader.h" -#endif - -#include - -#include "17live/CefDummy.hpp" #include "17live/OneSevenLiveCoreManager.hpp" +#include "17live/utility/Common.hpp" using namespace std; @@ -47,22 +46,29 @@ OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US") bool obs_module_load(void) { obs_log(LOG_INFO, "[%s] loading (version %s)", PLUGIN_NAME, PLUGIN_VERSION); -#if defined(__APPLE__) - /* Load CEF at runtime as required on macOS */ - CefScopedLibraryLoader library_loader; - if (!library_loader.LoadInMain()) { - obs_log(LOG_ERROR, "Failed to load CEF library"); - return false; - } -#endif - - cef_view_load(); // Initialize CEF view functionality - - obs_log(LOG_INFO, "[%s] loaded successfully (version %s)", PLUGIN_NAME, PLUGIN_VERSION); + InitThreadPool(); return true; } +static void schedule_init_core_impl(QMainWindow* mainWindow, bool* isRunningPtr) { + try { + auto& manager = OneSevenLiveCoreManager::getInstance(mainWindow); + if (!manager.initialize()) { + obs_log(LOG_ERROR, "OneSevenLiveCoreManager initialization failed"); + if (isRunningPtr) + *isRunningPtr = false; + return; + } + obs_log(LOG_INFO, "OneSevenLiveCoreManager initialized successfully"); + } catch (const std::exception& e) { + obs_log(LOG_ERROR, "OneSevenLiveCoreManager initialization exception: %s", e.what()); + if (isRunningPtr) + *isRunningPtr = false; + return; + } +} + void handle_obs_frontend_event(enum obs_frontend_event event, [[maybe_unused]] void* data) { static bool isRunning = true; @@ -89,19 +95,7 @@ void handle_obs_frontend_event(enum obs_frontend_event event, [[maybe_unused]] v mainWindow->statusBar()->addWidget(label); // Initialize OneSevenLiveCoreManager - try { - auto& manager = OneSevenLiveCoreManager::getInstance(mainWindow); - if (!manager.initialize()) { - obs_log(LOG_ERROR, "OneSevenLiveCoreManager initialization failed"); - isRunning = false; - return; - } - obs_log(LOG_INFO, "OneSevenLiveCoreManager initialized successfully"); - } catch (const std::exception& e) { - obs_log(LOG_ERROR, "OneSevenLiveCoreManager initialization exception: %s", e.what()); - isRunning = false; - return; - } + schedule_init_core_impl(mainWindow, &isRunning); obs_log(LOG_INFO, "[obs-17live]: init done"); break; @@ -121,14 +115,24 @@ void handle_obs_frontend_event(enum obs_frontend_event event, [[maybe_unused]] v // Release OneSevenLiveCoreManager resources try { auto& manager = OneSevenLiveCoreManager::getInstance(); + manager.setShuttingDown(true); manager.shutdown(); + + // Wait for all background tasks to complete BEFORE destroying the manager + // This ensures tasks don't access destroyed members (like apiWrapper or m_cancelFlag) + DestroyThreadPool(); + + OneSevenLiveCoreManager::destroyInstance(); + + // Force process deferred deletions (like QDockWidget::deleteLater) + // to ensure widgets are destroyed before the plugin library is unloaded + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + obs_log(LOG_INFO, "OneSevenLiveCoreManager resources released"); } catch (const std::exception& e) { obs_log(LOG_ERROR, "OneSevenLiveCoreManager resource release exception: %s", e.what()); } - cef_view_unload(); - obs_log(LOG_INFO, "shutdown complete"); break; } @@ -142,5 +146,7 @@ MODULE_EXPORT void obs_module_post_load(void) { } void obs_module_unload(void) { + // Ensure thread pool is destroyed on unload as well + DestroyThreadPool(); obs_log(LOG_INFO, "[obs-17live] plugin unloaded"); } diff --git a/src/plugin-support.c.in b/src/plugin-support.c.in index bbcb6d1..a5e0287 100644 --- a/src/plugin-support.c.in +++ b/src/plugin-support.c.in @@ -16,25 +16,30 @@ You should have received a copy of the GNU General Public License along with this program. If not, see */ +#include #include +#include const char *PLUGIN_NAME = "@CMAKE_PROJECT_NAME@"; const char *PLUGIN_VERSION = "@CMAKE_PROJECT_VERSION@"; const char *ONESEVENLIVE_API_URL = "@ONESEVENLIVE_API_URL@"; +const char *YOUTUBE_API_CLIENT_ID = "@YOUTUBE_API_CLIENT_ID@"; +const char *YOUTUBE_API_CLIENT_SECRET = "@YOUTUBE_API_CLIENT_SECRET@"; +const char *TWITCH_API_CLIENT_ID = "@TWITCH_API_CLIENT_ID@"; void obs_log(int log_level, const char *format, ...) { - size_t length = 4 + strlen(PLUGIN_NAME) + strlen(format); + size_t length = 4 + strlen(PLUGIN_NAME) + strlen(format); - char *template = malloc(length + 1); + char *template = malloc(length + 1); - snprintf(template, length, "[%s] %s", PLUGIN_NAME, format); + snprintf(template, length + 1, "[%s] %s", PLUGIN_NAME, format); - va_list(args); + va_list args; - va_start(args, format); - blogva(log_level, template, args); - va_end(args); + va_start(args, format); + blogva(log_level, template, args); + va_end(args); - free(template); + free(template); } diff --git a/src/plugin-support.h b/src/plugin-support.h index 99d4c24..1820b6e 100644 --- a/src/plugin-support.h +++ b/src/plugin-support.h @@ -30,9 +30,11 @@ extern "C" { extern const char *PLUGIN_NAME; extern const char *PLUGIN_VERSION; extern const char *ONESEVENLIVE_API_URL; +extern const char *YOUTUBE_API_CLIENT_ID; +extern const char *YOUTUBE_API_CLIENT_SECRET; +extern const char *TWITCH_API_CLIENT_ID; void obs_log(int log_level, const char *format, ...); -extern void blogva(int log_level, const char *format, va_list args); #ifdef __cplusplus } diff --git a/src/windows_stdio_compat.cpp b/src/windows_stdio_compat.cpp new file mode 100644 index 0000000..0cbf2b8 --- /dev/null +++ b/src/windows_stdio_compat.cpp @@ -0,0 +1,63 @@ +// Windows stdio compatibility layer for MbedTLS with static runtime +// This provides the dynamic runtime symbols that MbedTLS expects when using static runtime + +#ifdef _WIN32 + +#include +#include + +// Provide the __imp_* symbols that MbedTLS expects for dynamic runtime +// These redirect to the static runtime equivalents + +extern "C" { + +// stdio function compatibility +void __cdecl __imp_setbuf(FILE* stream, char* buffer) { + setbuf(stream, buffer); +} + +int __cdecl __imp_ferror(FILE* stream) { + return ferror(stream); +} + +int __cdecl __imp_remove(const char* filename) { + return remove(filename); +} + +// Additional stdio functions that might be needed +int __cdecl __imp_fclose(FILE* stream) { + return fclose(stream); +} + +FILE* __cdecl __imp_fopen(const char* filename, const char* mode) { + return fopen(filename, mode); +} + +size_t __cdecl __imp_fread(void* buffer, size_t size, size_t count, FILE* stream) { + return fread(buffer, size, count, stream); +} + +size_t __cdecl __imp_fwrite(const void* buffer, size_t size, size_t count, FILE* stream) { + return fwrite(buffer, size, count, stream); +} + +// Memory function compatibility +void* __cdecl __imp_calloc(size_t num, size_t size) { + return calloc(num, size); +} + +void __cdecl __imp_free(void* memblock) { + free(memblock); +} + +void* __cdecl __imp_malloc(size_t size) { + return malloc(size); +} + +void* __cdecl __imp_realloc(void* memblock, size_t size) { + return realloc(memblock, size); +} + +} // extern "C" + +#endif // _WIN32 \ No newline at end of file diff --git a/web/ably_chat/messages/en.d.json.ts b/web/ably_chat/messages/en.d.json.ts index 427a8bc..b9a4767 100644 --- a/web/ably_chat/messages/en.d.json.ts +++ b/web/ably_chat/messages/en.d.json.ts @@ -6,10 +6,25 @@ declare const messages: { "AI_COHOST": "AI Assistant", "GIVE_GIFT": "Sent a gift to the streamer", "GIVE_LUCKYBAG_GIFT": "Opened {luckyBagName} and sent {giftName} ({point})", - "EMPTY_CHAT_MESSAGE": "Pay attention to the chat to stay updated on the audience's trends", + "GIVE_GIFT_DEFAULT": "Sent a gift ({point})", + "EMPTY_CHAT_MESSAGE": "Audience comments will show up here. Have fun with your viewers!", "POKE_ONE": "Streamer pokes {receiverName}", "POKE_BACK": "pokes back", "POKE_ALL": "Streamer pokes All" + }, + "PlatformSelector": { + "label": "Platform Selection", + "platforms": { + "all": "All", + "17live": "17live", + "youtube": "YouTube", + "twitch": "Twitch" + }, + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "format": "{status}" + } } }; export default messages; \ No newline at end of file diff --git a/web/ably_chat/messages/en.json b/web/ably_chat/messages/en.json index 9c27af8..5ba9b29 100644 --- a/web/ably_chat/messages/en.json +++ b/web/ably_chat/messages/en.json @@ -3,9 +3,24 @@ "AI_COHOST": "AI Assistant", "GIVE_GIFT": "Sent a gift to the streamer", "GIVE_LUCKYBAG_GIFT": "Opened {luckyBagName} and sent {giftName} ({point})", + "GIVE_GIFT_DEFAULT": "Sent a gift ({point})", "EMPTY_CHAT_MESSAGE": "Audience comments will show up here. Have fun with your viewers!", "POKE_ONE": "Streamer pokes {receiverName}", "POKE_BACK": "pokes back", "POKE_ALL": "Streamer pokes All" + }, + "PlatformSelector": { + "label": "Platform Selection", + "platforms": { + "all": "All", + "17live": "17live", + "youtube": "YouTube", + "twitch": "Twitch" + }, + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "format": "{status}" + } } } \ No newline at end of file diff --git a/web/ably_chat/messages/ja.json b/web/ably_chat/messages/ja.json index 4869b5d..cc71e48 100644 --- a/web/ably_chat/messages/ja.json +++ b/web/ably_chat/messages/ja.json @@ -3,9 +3,24 @@ "AI_COHOST": "AIアシスタント", "GIVE_GIFT": "配信者にギフトを贈りました", "GIVE_LUCKYBAG_GIFT": "{luckyBagName} を開けて、{giftName}({point})を贈りました", + "GIVE_GIFT_DEFAULT": "ギフトを贈りました ({point})", "EMPTY_CHAT_MESSAGE": "リスナーのコメントはここに表示されます。", "POKE_ONE": "ライバーが {receiverName} にPokeしました", "POKE_BACK": "がPokeを返しました", "POKE_ALL": "ライバーが 全て にPokeしました" + }, + "PlatformSelector": { + "label": "プラットフォーム選択", + "platforms": { + "all": "すべて", + "17live": "17live", + "youtube": "YouTube", + "twitch": "Twitch" + }, + "status": { + "connected": "接続済み", + "disconnected": "未接続", + "format": "{status}" + } } } \ No newline at end of file diff --git a/web/ably_chat/messages/zh.json b/web/ably_chat/messages/zh.json index e3aafd4..fc76b5b 100644 --- a/web/ably_chat/messages/zh.json +++ b/web/ably_chat/messages/zh.json @@ -3,9 +3,24 @@ "AI_COHOST": "AI 助理", "GIVE_GIFT": "送給主播", "GIVE_LUCKYBAG_GIFT": "打開了 {luckyBagName},送出 {giftName} ({point})", + "GIVE_GIFT_DEFAULT": "送了一個禮物 ({point})", "EMPTY_CHAT_MESSAGE": "觀眾留言會出現在這裡,和大家互動吧!", "POKE_ONE": "主播戳了 {receiverName}", "POKE_BACK": "回戳了主播", "POKE_ALL": "主播戳了 所有觀眾" + }, + "PlatformSelector": { + "label": "平台選擇", + "platforms": { + "all": "全部", + "17live": "17live", + "youtube": "YouTube", + "twitch": "Twitch" + }, + "status": { + "connected": "已連線", + "disconnected": "未連線", + "format": "{status}" + } } } \ No newline at end of file diff --git a/web/ably_chat/package-lock.json b/web/ably_chat/package-lock.json index 3358b4c..00ec195 100644 --- a/web/ably_chat/package-lock.json +++ b/web/ably_chat/package-lock.json @@ -1,92 +1,73 @@ { - "name": "ably-testing", + "name": "chatroom-17live", "version": "0.1.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ably-testing", + "name": "chatroom-17live", "version": "0.1.0", "dependencies": { + "@types/styled-components": "^5.1.35", "ably": "^2.12.0", + "axios": "^1.13.2", "immutable": "^3.8.2", - "immutable-v4": "npm:immutable@4.0.0-rc.12", "lodash": "^4.17.21", + "lucide-react": "^0.553.0", + "nanoid": "^5.0.7", "next": "15.3.2", "next-intl": "^4.1.0", "pako": "^1.0.6", - "polished": "^4.3.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-inlinesvg": "^4.2.0", "rxjs": "^7.8.2", - "shortid": "^2.2.17", - "styled-components": "^6.1.18", - "styled-system": "^5.1.5" + "styled-components": "^6.1.19", + "tmi.js": "^1.8.5" }, "devDependencies": { "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", "eslint": "^9", - "eslint-config-next": "15.3.2", - "tailwindcss": "^4" + "eslint-config-next": "15.3.2" } }, "node_modules/@ably/msgpack-js": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.0.tgz", - "integrity": "sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.1.tgz", + "integrity": "sha512-Sjxj6SOr17hExAVrsycN7u6oV4PhZcK7Z2S8dM71CH/butgO47cSo/TL6FJPCXUyDAzKkOWjMUpJGyZkEpyu4Q==", "license": "Apache-2.0", "dependencies": { "bops": "^1.0.1" } }, - "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, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", + "integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", + "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -114,10 +95,11 @@ "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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" }, @@ -136,6 +118,7 @@ "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" }, @@ -144,21 +127,23 @@ } }, "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==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -167,19 +152,24 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -192,6 +182,7 @@ "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", @@ -211,30 +202,36 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "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==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -242,57 +239,54 @@ } }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", - "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.1", + "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, - "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", - "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/@formatjs/fast-memoize": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", "dependencies": { "tslib": "^2.8.0" } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", - "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/icu-skeleton-parser": "1.8.14", + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", - "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", "dependencies": { - "tslib": "2" + "tslib": "^2.8.0" } }, "node_modules/@humanfs/core": { @@ -300,41 +294,31 @@ "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.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "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.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "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" }, @@ -348,6 +332,7 @@ "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" }, @@ -356,13 +341,24 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", - "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -374,16 +370,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", - "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -395,16 +392,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.1.0" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -414,12 +412,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -429,12 +428,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -444,12 +444,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -459,12 +460,29 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -474,12 +492,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -489,12 +508,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", - "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -504,12 +524,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -519,12 +540,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", - "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -534,12 +556,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", - "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -551,16 +574,61 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.1.0" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", - "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -572,16 +640,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.1.0" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", - "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -593,16 +662,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.1.0" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", - "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -614,16 +684,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.1.0" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", - "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -635,16 +706,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", - "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -656,19 +728,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", - "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -677,13 +750,33 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", - "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -696,12 +789,13 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", - "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -713,49 +807,31 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz", - "integrity": "sha512-OKRBiajrrxB9ATokgEQoG87Z25c67pCpYcCwmXYX8PBftC9pBfN18gnm/fh1wurSLEKIAt+QRFLFCQISrb66Jg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.0", - "@emnapi/runtime": "^1.4.0", - "@tybys/wasm-util": "^0.9.0" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@next/env": { "version": "15.3.2", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz", - "integrity": "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==" + "integrity": "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==", + "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { "version": "15.3.2", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.2.tgz", "integrity": "sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==", "dev": true, + "license": "MIT", "dependencies": { "fast-glob": "3.3.1" } @@ -767,6 +843,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -782,6 +859,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -797,6 +875,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -812,6 +891,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -827,6 +907,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -842,6 +923,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -857,6 +939,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -872,6 +955,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -885,6 +969,7 @@ "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" @@ -898,6 +983,7 @@ "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" } @@ -907,6 +993,7 @@ "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" @@ -920,6 +1007,7 @@ "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.4.0" } @@ -928,18 +1016,21 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", - "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", - "dev": true + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.1.tgz", + "integrity": "sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==", + "dev": true, + "license": "MIT" }, "node_modules/@schummar/icu-type-parser": { "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", - "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==" + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", @@ -953,404 +1044,248 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@styled-system/background": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", - "integrity": "sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==", - "license": "MIT", + "node_modules/@swc/core": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.1.tgz", + "integrity": "sha512-s9GN3M2jA32k+StvuS9uGe4ztf5KVGBdlJMMC6LR6Ah23Lq/CWKVcC3WeQi8qaAcLd+DiddoNCNMUWymLv+wWQ==", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@styled-system/core": "^5.1.2" + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.1", + "@swc/core-darwin-x64": "1.15.1", + "@swc/core-linux-arm-gnueabihf": "1.15.1", + "@swc/core-linux-arm64-gnu": "1.15.1", + "@swc/core-linux-arm64-musl": "1.15.1", + "@swc/core-linux-x64-gnu": "1.15.1", + "@swc/core-linux-x64-musl": "1.15.1", + "@swc/core-win32-arm64-msvc": "1.15.1", + "@swc/core-win32-ia32-msvc": "1.15.1", + "@swc/core-win32-x64-msvc": "1.15.1" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } } }, - "node_modules/@styled-system/border": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz", - "integrity": "sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.1.tgz", + "integrity": "sha512-vEPrVxegWIjKEz+1VCVuKRY89jhokhSmQ/YXBWLnmLj9cI08G61RTZJvdsIcjYUjjTu7NgZlYVK+b2y0fbh11g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@styled-system/color": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz", - "integrity": "sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.1.tgz", + "integrity": "sha512-z9QguKxE3aldvwKHHDg5OlKehasbJBF1lacn5CnN6SlrHbdwokXHFA3nIoO3Bh1Tw7bCgFtdIR4jKlTTn3kBZA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@styled-system/core": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz", - "integrity": "sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1" + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.1.tgz", + "integrity": "sha512-yS2FHA8E4YeiPG9YeYk/6mKiCWuXR5RdYlCmtlGzKcjWbI4GXUVe7+p9C0M6myRt3zdj3M1knmJxk52MQA9EZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@styled-system/css": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz", - "integrity": "sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A==", - "license": "MIT" - }, - "node_modules/@styled-system/flexbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz", - "integrity": "sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.1.tgz", + "integrity": "sha512-IFrjDu7+5Y61jLsUqBVXlXutDoPBX10eEeNTjW6C1yzm+cSTE7ayiKXMIFri4gEZ4VpXS6MUgkwjxtDpIXTh+w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@styled-system/grid": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz", - "integrity": "sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.1.tgz", + "integrity": "sha512-fKzP9mRQGbhc5QhJPIsqKNNX/jyWrZgBxmo3Nz1SPaepfCUc7RFmtcJQI5q8xAun3XabXjh90wqcY/OVyg2+Kg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@styled-system/layout": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz", - "integrity": "sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/position": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz", - "integrity": "sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/shadow": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz", - "integrity": "sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/space": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz", - "integrity": "sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/typography": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz", - "integrity": "sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/variant": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz", - "integrity": "sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2", - "@styled-system/css": "^5.1.5" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz", - "integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==", - "dev": true, - "dependencies": { - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.29.2", - "tailwindcss": "4.1.5" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz", - "integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==", - "dev": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.5", - "@tailwindcss/oxide-darwin-arm64": "4.1.5", - "@tailwindcss/oxide-darwin-x64": "4.1.5", - "@tailwindcss/oxide-freebsd-x64": "4.1.5", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", - "@tailwindcss/oxide-linux-x64-musl": "4.1.5", - "@tailwindcss/oxide-wasm32-wasi": "4.1.5", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz", - "integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz", - "integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz", - "integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==", + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.1.tgz", + "integrity": "sha512-ZLjMi138uTJxb+1wzo4cB8mIbJbAsSLWRNeHc1g1pMvkERPWOGlem+LEYkkzaFzCNv1J8aKcL653Vtw8INHQeg==", "cpu": [ "x64" ], - "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz", - "integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==", + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.1.tgz", + "integrity": "sha512-jvSI1IdsIYey5kOITzyajjofXOOySVitmLxb45OPUjoNojql4sDojvlW5zoHXXFePdA6qAX4Y6KbzAOV3T3ctA==", "cpu": [ "x64" ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz", - "integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==", - "cpu": [ - "arm" - ], - "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz", - "integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==", + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.1.tgz", + "integrity": "sha512-X/FcDtNrDdY9r4FcXHt9QxUqC/2FbQdvZobCKHlHe8vTSKhUHOilWl5EBtkFVfsEs4D5/yAri9e3bJbwyBhhBw==", "cpu": [ "arm64" ], - "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz", - "integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==", + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.1.tgz", + "integrity": "sha512-vfheiWBux8PpC87oy1cshcqzgH7alWYpnVq5jWe7xuVkjqjGGDbBUKuS84eJCdsWcVaB5EXIWLKt+11W3/BOwA==", "cpu": [ - "arm64" + "ia32" ], - "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz", - "integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==", + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.1.tgz", + "integrity": "sha512-n3Ppn0LSov/IdlANq+8kxHqENuJRX5XtwQqPgQsgwKIcFq22u17NKfDs9vL5PwRsEHY6Xd67pnOqQX0h4AvbuQ==", "cpu": [ "x64" ], - "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz", - "integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz", - "integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", - "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz", - "integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz", - "integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.5.tgz", - "integrity": "sha512-5lAC2/pzuyfhsFgk6I58HcNy6vPK3dV/PoPxSDuOTVbDvCddYHzHiJZZInGIY0venvzzfrTEUAXJFULAfFmObg==", - "dev": true, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.5", - "@tailwindcss/oxide": "4.1.5", - "postcss": "^8.4.41", - "tailwindcss": "4.1.5" + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" } }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1369,10 +1304,23 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true + "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/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", @@ -1384,13 +1332,15 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/keyv": { "version": "3.1.4", @@ -1402,12 +1352,21 @@ } }, "node_modules/@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "csstype": "^3.0.2" } }, "node_modules/@types/responselike": { @@ -1419,6 +1378,17 @@ "@types/node": "*" } }, + "node_modules/@types/styled-components": { + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.35.tgz", + "integrity": "sha512-JeYII52nSFGXGaw/5Odf0TBUhT3024HduBewrZCQBoUFKBw8V6x1dbnZCpgJuzmiokWAlVo3kkS3k3jrEK1NyA==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/stylis": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", @@ -1426,18 +1396,19 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", - "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", + "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/type-utils": "8.32.0", - "@typescript-eslint/utils": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/type-utils": "8.46.3", + "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, @@ -1449,21 +1420,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.46.3", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.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.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", - "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", + "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4" }, "engines": { @@ -1475,34 +1457,76 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "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.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", - "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0" + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" + }, + "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.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "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.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", - "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", + "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/utils": "8.32.0", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/utils": "8.46.3", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1515,14 +1539,15 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", - "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1532,13 +1557,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", - "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1554,14 +1582,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "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" } @@ -1571,6 +1600,7 @@ "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", @@ -1587,6 +1617,7 @@ "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" }, @@ -1599,6 +1630,7 @@ "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" }, @@ -1610,15 +1642,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", - "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0" + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1629,17 +1662,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", - "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.32.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1649,234 +1683,280 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-vxtBno4xvowwNmO/ASL0Y45TpHqmNkAaDtz4Jqb+clmcVSSl8XCG/PNFFkGsXXXS6AMjP+ja/TtNCFFa1QwLRg==", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", "cpu": [ - "arm64" + "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qhVa8ozu92C23Hsmv0BF4+5Dyyd5STT1FolV4whNgbY6mj3kA0qsrGPe35zNR3wAN7eFict3s4Rc2dDTPBTuFQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.7.2.tgz", - "integrity": "sha512-zKKdm2uMXqLFX6Ac7K5ElnnG5VIXbDlFWzg4WJ8CGUedJryM5A3cTgHuGMw1+P5ziV8CRhnSEgOnurTI4vpHpg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-8N1z1TbPnHH+iDS/42GJ0bMPLiGK+cUqOhNbMKtWJ4oFGzqSJk/zoXFzcQkgtI63qMcUI7wW1tq2usZQSb2jxw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.7.2.tgz", - "integrity": "sha512-tjYzI9LcAXR9MYd9rO45m1s0B/6bJNuZ6jeOxo1pq1K6OBuRMMmfyvJYval3s9FPPGmrldYA3mi4gWDlWuTFGA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-jon9M7DKRLGZ9VYSkFMflvNqu9hDtOCEnO2QAryFWgT6o6AXU8du56V7YqnaLKr6rAbZBWYsYpikF226v423QA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-c8Cg4/h+kQ63pL43wBNaVMmOjXI/X62wQmru51qjfTvI7kmCy5uHTJvK/9LrF0G8Jdx8r34d019P1DVJmhXQpA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.7.2.tgz", - "integrity": "sha512-A+lcwRFyrjeJmv3JJvhz5NbcCkLQL6Mk16kHTNm6/aGNc4FwPHPE4DR9DwuCvCnVHvF5IAd9U4VIs/VvVir5lg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.7.2.tgz", - "integrity": "sha512-hQQ4TJQrSQW8JlPm7tRpXN8OCNP9ez7PajJNjRD1ZTHQAy685OYqPrKjfaMw/8LiHCt8AZ74rfUVHP9vn0N69Q==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.7.2.tgz", - "integrity": "sha512-NoAGbiqrxtY8kVooZ24i70CjLDlUFI7nDj3I9y54U94p+3kPxwd2L692YsdLa+cqQ0VoqMWoehDFp21PKRUoIQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.7.2.tgz", - "integrity": "sha512-KaZByo8xuQZbUhhreBTW+yUnOIHUsv04P8lKjQ5otiGoSJ17ISGYArc+4vKdLEpGaLbemGzr4ZeUbYQQsLWFjA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-dEidzJDubxxhUCBJ/SHSMJD/9q7JkyfBMT77Px1npl4xpg9t0POLvnWywSk66BgZS/b2Hy9Y1yFaoMTFJUe9yg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-RvP+Ux3wDjmnZDT4XWFfNBRVG0fMsc+yVzNFUqOflnDfZ9OYujv6nkh+GOr+watwrW4wdp6ASfG/e7bkDradsw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.7.2.tgz", - "integrity": "sha512-y797JBmO9IsvXVRCKDXOxjyAE4+CcZpla2GSoBQ33TVb3ILXuFnMrbR/QQZoauBYeOFuu4w3ifWLw52sdHGz6g==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", "cpu": [ "wasm32" ], "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.9" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-gtYTh4/VREVSLA+gHrfbWxaMO/00y+34htY7XpioBTy56YN2eBjkPrY1ML1Zys89X3RJDKVaogzwxlM1qU7egg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-Ywv20XHvHTDRQs12jd3MY8X5C8KLjDbg/jyaal/QLKx3fAShhJyD4blEANInsjxW3P7isHx1Blt56iUDDJO3jg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-friS8NEQfHaDbkThxopGk+LuE5v3iY0StruifjQEt7SLbA46OnfgMO15sOTkbpJkol6RB+1l1TYPXh0sCddpvA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/ably": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/ably/-/ably-2.12.0.tgz", - "integrity": "sha512-sU2LSpXXxesYv+CbX/p/+w0dHUkn8+V92iusZVvhQBQpzUbENGvjsVlt5gph8Q/FzdY67lwTMFYQ6yo5od2SJw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/ably/-/ably-2.14.0.tgz", + "integrity": "sha512-GWNza+URnh/W5IuoJX7nXJpQCs2Dxby6t5A20vL3PBqGIJceA94/1xje4HOZbqFtMEPkRVsYHBIEuQRWL+CuvQ==", + "license": "Apache-2.0", "dependencies": { "@ably/msgpack-js": "^0.4.0", "dequal": "^2.0.3", @@ -1901,24 +1981,12 @@ } } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "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" }, @@ -1931,6 +1999,7 @@ "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" } @@ -1940,6 +2009,7 @@ "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", @@ -1956,6 +2026,7 @@ "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" }, @@ -1970,13 +2041,15 @@ "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 + "dev": true, + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">= 0.4" } @@ -1986,6 +2059,7 @@ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -1998,17 +2072,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2022,6 +2099,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -2042,6 +2120,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", @@ -2063,6 +2142,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -2081,6 +2161,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -2099,6 +2180,7 @@ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -2115,6 +2197,7 @@ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", @@ -2135,22 +2218,31 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, + "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/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" }, @@ -2162,19 +2254,32 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, + "license": "MPL-2.0", "engines": { "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">= 0.4" } @@ -2183,7 +2288,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/base64-js": { "version": "1.0.2", @@ -2194,26 +2300,6 @@ "node": ">= 0.4" } }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/bops": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", @@ -2225,10 +2311,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "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" @@ -2239,6 +2326,7 @@ "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" }, @@ -2257,15 +2345,6 @@ "node": ">=10.16.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -2298,6 +2377,7 @@ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", @@ -2315,7 +2395,7 @@ "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==", - "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2329,6 +2409,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -2345,6 +2426,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2359,9 +2441,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001717", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", - "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "funding": [ { "type": "opencollective", @@ -2375,13 +2457,15 @@ "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" @@ -2396,7 +2480,8 @@ "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" }, "node_modules/clone-response": { "version": "1.0.3", @@ -2410,24 +2495,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "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==", - "devOptional": true, + "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -2439,81 +2512,34 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true + "dev": true, + "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, + "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": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" } }, "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 - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } + "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", @@ -2553,13 +2579,15 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -2577,6 +2605,7 @@ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -2594,6 +2623,7 @@ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -2607,10 +2637,11 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "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" }, @@ -2624,9 +2655,10 @@ } }, "node_modules/decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==" + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" }, "node_modules/decompress-response": { "version": "6.0.0", @@ -2659,7 +2691,8 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/defer-to-connect": { "version": "2.0.1", @@ -2675,6 +2708,7 @@ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -2692,6 +2726,7 @@ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -2704,28 +2739,30 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, + "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.8" + "node": ">=0.4.0" } }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true, + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -2735,6 +2772,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -2746,7 +2784,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2756,71 +2794,45 @@ "node": ">= 0.4" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, "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 - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, - "engines": { - "node": ">= 0.8" - } + "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -2832,21 +2844,24 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -2855,7 +2870,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -2868,7 +2883,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -2877,7 +2892,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -2887,6 +2902,7 @@ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -2913,7 +2929,7 @@ "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==", - "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -2925,7 +2941,7 @@ "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==", - "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -2941,6 +2957,7 @@ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -2953,6 +2970,7 @@ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, + "license": "MIT", "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", @@ -2965,17 +2983,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, "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" }, @@ -2984,33 +2997,32 @@ } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@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.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.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", @@ -3024,8 +3036,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -3050,6 +3061,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.2.tgz", "integrity": "sha512-FerU4DYccO4FgeYFFglz0SnaKRe1ejXQrDb8kWUkTAg036YWi+jUsgg4sIGNCDhAsDITsZaL4MzBWKB6f4G1Dg==", "dev": true, + "license": "MIT", "dependencies": { "@next/eslint-plugin-next": "15.3.2", "@rushstack/eslint-patch": "^1.10.3", @@ -3077,6 +3089,7 @@ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -3088,6 +3101,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } @@ -3097,6 +3111,7 @@ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", "dev": true, + "license": "ISC", "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", @@ -3127,10 +3142,11 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7" }, @@ -3148,34 +3164,36 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -3190,6 +3208,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } @@ -3199,6 +3218,7 @@ "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" } @@ -3208,6 +3228,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, + "license": "MIT", "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -3237,6 +3258,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -3269,6 +3291,7 @@ "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" }, @@ -3281,6 +3304,7 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, + "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -3298,15 +3322,17 @@ "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/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "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" @@ -3319,10 +3345,11 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "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" }, @@ -3331,14 +3358,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "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.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3352,6 +3380,7 @@ "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" }, @@ -3364,6 +3393,7 @@ "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" }, @@ -3376,6 +3406,7 @@ "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" } @@ -3385,108 +3416,24 @@ "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/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "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, - "engines": { - "node": ">= 0.6" - } + "license": "MIT" }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, - "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 - }, - "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -3503,6 +3450,7 @@ "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" }, @@ -3514,13 +3462,15 @@ "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 + "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 + "dev": true, + "license": "MIT" }, "node_modules/fastestsmallesttextencoderdecoder": { "version": "1.0.22", @@ -3533,6 +3483,7 @@ "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" } @@ -3542,6 +3493,7 @@ "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" }, @@ -3554,6 +3506,7 @@ "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" }, @@ -3561,28 +3514,12 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.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" @@ -3599,6 +3536,7 @@ "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" @@ -3611,13 +3549,35 @@ "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 + "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/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, + "license": "MIT", "dependencies": { "is-callable": "^1.2.7" }, @@ -3628,29 +3588,27 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, + "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": ">= 0.8" + "node": ">= 6" } }, "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==", - "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3660,6 +3618,7 @@ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -3680,15 +3639,26 @@ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "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==", - "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3712,7 +3682,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, + "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3741,6 +3711,7 @@ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -3754,10 +3725,11 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, + "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -3770,6 +3742,7 @@ "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" }, @@ -3782,6 +3755,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3794,6 +3768,7 @@ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, + "license": "MIT", "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -3809,7 +3784,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3842,23 +3817,19 @@ "url": "https://github.com/sindresorhus/got?sponsor=1" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3871,6 +3842,7 @@ "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" } @@ -3880,6 +3852,7 @@ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -3892,6 +3865,7 @@ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, + "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" }, @@ -3906,7 +3880,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -3918,7 +3892,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -3933,7 +3907,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -3941,28 +3915,21 @@ "node": ">= 0.4" } }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "license": "BSD-2-Clause" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" + "react-is": "^16.7.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -3976,23 +3943,12 @@ "node": ">=10.19.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "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" } @@ -4006,18 +3962,12 @@ "node": ">=0.10.0" } }, - "node_modules/immutable-v4": { - "name": "immutable", - "version": "4.0.0-rc.12", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", - "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==", - "license": "MIT" - }, "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" @@ -4034,21 +3984,17 @@ "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/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -4059,30 +4005,23 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.16", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", - "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.2", + "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -4095,17 +4034,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, + "license": "MIT", "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", @@ -4125,6 +4059,7 @@ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, + "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" }, @@ -4140,6 +4075,7 @@ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -4156,6 +4092,7 @@ "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.7.1" } @@ -4165,6 +4102,7 @@ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -4177,6 +4115,7 @@ "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" }, @@ -4192,6 +4131,7 @@ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", @@ -4209,6 +4149,7 @@ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -4225,6 +4166,7 @@ "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" } @@ -4234,6 +4176,7 @@ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3" }, @@ -4245,13 +4188,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -4267,6 +4212,7 @@ "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" }, @@ -4279,6 +4225,20 @@ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -4291,6 +4251,7 @@ "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" } @@ -4300,6 +4261,7 @@ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -4311,17 +4273,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -4340,6 +4297,7 @@ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -4352,6 +4310,7 @@ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3" }, @@ -4367,6 +4326,7 @@ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -4383,6 +4343,7 @@ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -4400,6 +4361,7 @@ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, + "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" }, @@ -4415,6 +4377,7 @@ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -4427,6 +4390,7 @@ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3" }, @@ -4442,6 +4406,7 @@ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -4457,19 +4422,22 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -4483,10 +4451,13 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, + "license": "MIT", + "optional": true, + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -4495,13 +4466,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "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" }, @@ -4512,25 +4485,29 @@ "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==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "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 + "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 + "dev": true, + "license": "MIT" }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.0" }, @@ -4543,6 +4520,7 @@ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -4557,6 +4535,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -4565,13 +4544,15 @@ "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true + "dev": true, + "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, + "license": "MIT", "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -4584,6 +4565,7 @@ "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" @@ -4592,247 +4574,20 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", - "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "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": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "p-locate": "^5.0.0" }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.2", - "lightningcss-darwin-x64": "1.29.2", - "lightningcss-freebsd-x64": "1.29.2", - "lightningcss-linux-arm-gnueabihf": "1.29.2", - "lightningcss-linux-arm64-gnu": "1.29.2", - "lightningcss-linux-arm64-musl": "1.29.2", - "lightningcss-linux-x64-gnu": "1.29.2", - "lightningcss-linux-x64-musl": "1.29.2", - "lightningcss-win32-arm64-msvc": "1.29.2", - "lightningcss-win32-x64-msvc": "1.29.2" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", - "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">= 12.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", - "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", - "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", - "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", - "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", - "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", - "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", - "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", - "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", - "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "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, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { @@ -4845,13 +4600,15 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, + "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4868,41 +4625,30 @@ "node": ">=8" } }, + "node_modules/lucide-react": { + "version": "0.553.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz", + "integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "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==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "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" } @@ -4912,6 +4658,7 @@ "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" @@ -4921,21 +4668,21 @@ } }, "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, + "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": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, + "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.54.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" @@ -4955,6 +4702,7 @@ "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" }, @@ -4967,6 +4715,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4975,30 +4724,33 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-postinstall": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.3.tgz", - "integrity": "sha512-Mi7JISo/4Ij2tDZ2xBE2WH+/KvVlkhA6juEjpEeRAVPNCpN3nxJo/5FhDNKgBcdmcmhaH6JjgST4xY/23ZYK0w==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, + "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" }, @@ -5013,12 +4765,14 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5027,6 +4781,7 @@ "version": "15.3.2", "resolved": "https://registry.npmjs.org/next/-/next-15.3.2.tgz", "integrity": "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==", + "license": "MIT", "dependencies": { "@next/env": "15.3.2", "@swc/counter": "0.1.3", @@ -5077,22 +4832,24 @@ } }, "node_modules/next-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.1.0.tgz", - "integrity": "sha512-JNJRjc7sdnfUxhZmGcvzDszZ60tQKrygV/VLsgzXhnJDxQPn1cN2rVpc53adA1SvBJwPK2O6Sc6b4gYSILjCzw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.0.tgz", + "integrity": "sha512-XglGmbs38smaN/QedFVzsypdI4l5tRMpjdU3UL2TtZ3d412oLmmM6enSnHSn0/P59b0ksIp+HlgoLjvHREj1EQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/amannn" } ], + "license": "MIT", "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", + "@swc/core": "^1.13.19", "negotiator": "^1.0.0", - "use-intl": "^4.1.0" + "use-intl": "^4.5.0" }, "peerDependencies": { - "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, @@ -5102,6 +4859,42 @@ } } }, + "node_modules/next-intl/node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5120,6 +4913,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -5129,6 +4923,26 @@ "node": "^10 || ^12 || >=14" } }, + "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/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -5145,6 +4959,8 @@ "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" } @@ -5154,6 +4970,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5166,6 +4983,7 @@ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -5175,6 +4993,7 @@ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -5195,6 +5014,7 @@ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", @@ -5210,6 +5030,7 @@ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -5228,6 +5049,7 @@ "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -5242,6 +5064,7 @@ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -5255,22 +5078,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -5280,6 +5092,7 @@ "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", @@ -5297,6 +5110,7 @@ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, + "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", @@ -5323,6 +5137,7 @@ "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" }, @@ -5338,6 +5153,7 @@ "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" }, @@ -5349,9 +5165,9 @@ } }, "node_modules/pako": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", - "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -5359,6 +5175,7 @@ "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" }, @@ -5366,20 +5183,12 @@ "node": ">=6" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "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" } @@ -5389,6 +5198,7 @@ "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" } @@ -5397,27 +5207,21 @@ "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 - }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "dev": true, - "engines": { - "node": ">=16" - } + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "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" }, @@ -5425,64 +5229,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "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" - } - ], - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -5494,6 +5250,7 @@ "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" } @@ -5503,29 +5260,23 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, + "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } + "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/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -5537,25 +5288,11 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5574,7 +5311,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/quick-lru": { "version": "5.1.1", @@ -5588,47 +5326,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "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.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "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.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^19.2.0" } }, "node_modules/react-from-dom": { @@ -5656,13 +5372,14 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "license": "MIT" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -5685,6 +5402,7 @@ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -5701,12 +5419,13 @@ } }, "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==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -5731,6 +5450,7 @@ "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" } @@ -5740,6 +5460,7 @@ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -5761,27 +5482,12 @@ "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/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5801,6 +5507,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -5819,6 +5526,7 @@ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -5833,31 +5541,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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" - } - ] - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" @@ -5874,6 +5563,7 @@ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5886,22 +5576,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + "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": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "devOptional": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5909,48 +5595,12 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -5968,6 +5618,7 @@ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -5983,6 +5634,7 @@ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, + "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", @@ -5992,12 +5644,6 @@ "node": ">= 0.4" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -6005,15 +5651,16 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", - "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, + "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.7.1" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -6022,26 +5669,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.1", - "@img/sharp-darwin-x64": "0.34.1", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.1", - "@img/sharp-linux-arm64": "0.34.1", - "@img/sharp-linux-s390x": "0.34.1", - "@img/sharp-linux-x64": "0.34.1", - "@img/sharp-linuxmusl-arm64": "0.34.1", - "@img/sharp-linuxmusl-x64": "0.34.1", - "@img/sharp-wasm32": "0.34.1", - "@img/sharp-win32-ia32": "0.34.1", - "@img/sharp-win32-x64": "0.34.1" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -6049,6 +5700,7 @@ "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" }, @@ -6061,24 +5713,17 @@ "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/shortid": { - "version": "2.2.17", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.17.tgz", - "integrity": "sha512-GpbM3gLF1UUXZvQw6MCyulHkWbRseNO4cyBEZresZRorwl1+SLu1ZdqgVtuwqz8mB6RpwPkm541mYSqrKyJSaA==", - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.8" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -6098,6 +5743,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -6114,6 +5760,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -6132,6 +5779,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -6146,19 +5794,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "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==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -6167,15 +5807,21 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" } }, "node_modules/streamsearch": { @@ -6191,6 +5837,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6205,6 +5852,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -6232,6 +5880,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, + "license": "MIT", "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -6242,6 +5891,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -6263,6 +5913,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -6281,6 +5932,7 @@ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6298,6 +5950,7 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6307,6 +5960,7 @@ "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" }, @@ -6315,9 +5969,9 @@ } }, "node_modules/styled-components": { - "version": "6.1.18", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.18.tgz", - "integrity": "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw==", + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz", + "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "license": "MIT", "dependencies": { "@emotion/is-prop-valid": "1.2.2", @@ -6342,6 +5996,24 @@ "react-dom": ">= 16.8.0" } }, + "node_modules/styled-components/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/styled-components/node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -6380,6 +6052,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", "dependencies": { "client-only": "0.0.1" }, @@ -6398,27 +6071,6 @@ } } }, - "node_modules/styled-system": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz", - "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==", - "license": "MIT", - "dependencies": { - "@styled-system/background": "^5.1.2", - "@styled-system/border": "^5.1.5", - "@styled-system/color": "^5.1.2", - "@styled-system/core": "^5.1.2", - "@styled-system/flexbox": "^5.1.2", - "@styled-system/grid": "^5.1.2", - "@styled-system/layout": "^5.1.2", - "@styled-system/position": "^5.1.2", - "@styled-system/shadow": "^5.1.2", - "@styled-system/space": "^5.1.2", - "@styled-system/typography": "^5.1.2", - "@styled-system/variant": "^5.1.5", - "object-assign": "^4.1.1" - } - }, "node_modules/stylis": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", @@ -6430,6 +6082,7 @@ "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" }, @@ -6442,6 +6095,7 @@ "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" }, @@ -6449,29 +6103,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz", - "integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==", - "dev": true - }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "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.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6481,10 +6121,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "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" }, @@ -6495,10 +6139,11 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "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" }, @@ -6506,11 +6151,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmi.js": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/tmi.js/-/tmi.js-1.8.5.tgz", + "integrity": "sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "ws": "^8.2.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "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" }, @@ -6524,20 +6183,18 @@ "integrity": "sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ==", "license": "MIT" }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "engines": { - "node": ">=0.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" }, @@ -6550,6 +6207,7 @@ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, + "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -6560,13 +6218,15 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "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" }, @@ -6574,25 +6234,12 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -6607,6 +6254,7 @@ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", @@ -6626,6 +6274,7 @@ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", @@ -6647,6 +6296,7 @@ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", @@ -6663,10 +6313,11 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, + "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -6690,6 +6341,7 @@ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", @@ -6704,50 +6356,44 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unrs-resolver": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.7.2.tgz", - "integrity": "sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "napi-postinstall": "^0.2.2" + "napi-postinstall": "^0.3.0" }, "funding": { - "url": "https://github.com/sponsors/JounQin" + "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-darwin-arm64": "1.7.2", - "@unrs/resolver-binding-darwin-x64": "1.7.2", - "@unrs/resolver-binding-freebsd-x64": "1.7.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.7.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.7.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.7.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.7.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.7.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-x64-musl": "1.7.2", - "@unrs/resolver-binding-wasm32-wasi": "1.7.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.7.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.7.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.7.2" + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "node_modules/uri-js": { @@ -6755,14 +6401,16 @@ "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/use-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.1.0.tgz", - "integrity": "sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.0.tgz", + "integrity": "sha512-H0w/sWilzbd1y0+fve2o6EnJ8B7bDwpI+pd1o/zjO717FVSi1clYe6dCmIyPc8NEKOS9BGm1IK9ipSY7OZAqZg==", + "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "^2.2.0", "@schummar/icu-type-parser": "1.21.5", @@ -6772,13 +6420,20 @@ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "engines": { - "node": ">= 0.8" + "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": { @@ -6786,6 +6441,7 @@ "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" }, @@ -6801,6 +6457,7 @@ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, + "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -6820,6 +6477,7 @@ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", @@ -6847,6 +6505,7 @@ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, + "license": "MIT", "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -6865,6 +6524,7 @@ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dev": true, + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", @@ -6886,6 +6546,7 @@ "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" } @@ -6893,12 +6554,13 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6921,4621 +6583,13 @@ "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" } - }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "peerDependencies": { - "zod": "^3.24.1" - } - } - }, - "dependencies": { - "@ably/msgpack-js": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@ably/msgpack-js/-/msgpack-js-0.4.0.tgz", - "integrity": "sha512-IPt/BoiQwCWubqoNik1aw/6M/DleMdrxJOUpSja6xmMRbT2p1TA8oqKWgfZabqzrq8emRNeSl/+4XABPNnW5pQ==", - "requires": { - "bops": "^1.0.1" - } - }, - "@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 - }, - "@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==" - }, - "@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "optional": true, - "requires": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "optional": true, - "requires": { - "tslib": "^2.4.0" - } - }, - "@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "optional": true, - "requires": { - "tslib": "^2.4.0" - } - }, - "@emotion/is-prop-valid": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", - "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", - "requires": { - "@emotion/memoize": "^0.8.1" - } - }, - "@emotion/memoize": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", - "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" - }, - "@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" - }, - "@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.4.3" - }, - "dependencies": { - "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 - } - } - }, - "@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 - }, - "@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", - "dev": true, - "requires": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - } - }, - "@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", - "dev": true - }, - "@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.15" - } - }, - "@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "requires": { - "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" - } - }, - "@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", - "dev": true - }, - "@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 - }, - "@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", - "dev": true, - "requires": { - "@eslint/core": "^0.13.0", - "levn": "^0.4.1" - } - }, - "@formatjs/ecma402-abstract": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", - "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", - "requires": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.1", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - }, - "dependencies": { - "@formatjs/intl-localematcher": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", - "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", - "requires": { - "tslib": "^2.8.0" - } - } - } - }, - "@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "requires": { - "tslib": "^2.8.0" - } - }, - "@formatjs/icu-messageformat-parser": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", - "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", - "requires": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/icu-skeleton-parser": "1.8.14", - "tslib": "^2.8.0" - } - }, - "@formatjs/icu-skeleton-parser": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", - "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", - "requires": { - "@formatjs/ecma402-abstract": "2.3.4", - "tslib": "^2.8.0" - } - }, - "@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", - "requires": { - "tslib": "2" - } - }, - "@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 - }, - "@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "requires": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "dependencies": { - "@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true - } - } - }, - "@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 - }, - "@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true - }, - "@img/sharp-darwin-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", - "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" - } - }, - "@img/sharp-darwin-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", - "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-x64": "1.1.0" - } - }, - "@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", - "optional": true - }, - "@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", - "optional": true - }, - "@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", - "optional": true - }, - "@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", - "optional": true - }, - "@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", - "optional": true - }, - "@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", - "optional": true - }, - "@img/sharp-libvips-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", - "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", - "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", - "optional": true - }, - "@img/sharp-linux-arm": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", - "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm": "1.1.0" - } - }, - "@img/sharp-linux-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", - "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm64": "1.1.0" - } - }, - "@img/sharp-linux-s390x": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", - "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-s390x": "1.1.0" - } - }, - "@img/sharp-linux-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", - "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-x64": "1.1.0" - } - }, - "@img/sharp-linuxmusl-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", - "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" - } - }, - "@img/sharp-linuxmusl-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", - "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.1.0" - } - }, - "@img/sharp-wasm32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", - "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", - "optional": true, - "requires": { - "@emnapi/runtime": "^1.4.0" - } - }, - "@img/sharp-win32-ia32": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", - "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", - "optional": true - }, - "@img/sharp-win32-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", - "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", - "optional": true - }, - "@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "requires": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - } - }, - "@napi-rs/wasm-runtime": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz", - "integrity": "sha512-OKRBiajrrxB9ATokgEQoG87Z25c67pCpYcCwmXYX8PBftC9pBfN18gnm/fh1wurSLEKIAt+QRFLFCQISrb66Jg==", - "dev": true, - "optional": true, - "requires": { - "@emnapi/core": "^1.4.0", - "@emnapi/runtime": "^1.4.0", - "@tybys/wasm-util": "^0.9.0" - } - }, - "@next/env": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz", - "integrity": "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==" - }, - "@next/eslint-plugin-next": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.2.tgz", - "integrity": "sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==", - "dev": true, - "requires": { - "fast-glob": "3.3.1" - } - }, - "@next/swc-darwin-arm64": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.2.tgz", - "integrity": "sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g==", - "optional": true - }, - "@next/swc-darwin-x64": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.2.tgz", - "integrity": "sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w==", - "optional": true - }, - "@next/swc-linux-arm64-gnu": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.2.tgz", - "integrity": "sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA==", - "optional": true - }, - "@next/swc-linux-arm64-musl": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.2.tgz", - "integrity": "sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg==", - "optional": true - }, - "@next/swc-linux-x64-gnu": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.2.tgz", - "integrity": "sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg==", - "optional": true - }, - "@next/swc-linux-x64-musl": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.2.tgz", - "integrity": "sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w==", - "optional": true - }, - "@next/swc-win32-arm64-msvc": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.2.tgz", - "integrity": "sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ==", - "optional": true - }, - "@next/swc-win32-x64-msvc": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.2.tgz", - "integrity": "sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA==", - "optional": true - }, - "@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, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@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 - }, - "@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, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true - }, - "@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true - }, - "@rushstack/eslint-patch": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", - "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", - "dev": true - }, - "@schummar/icu-type-parser": { - "version": "1.21.5", - "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", - "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==" - }, - "@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" - }, - "@styled-system/background": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", - "integrity": "sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/border": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz", - "integrity": "sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/color": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz", - "integrity": "sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/core": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz", - "integrity": "sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw==", - "requires": { - "object-assign": "^4.1.1" - } - }, - "@styled-system/css": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz", - "integrity": "sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A==" - }, - "@styled-system/flexbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz", - "integrity": "sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/grid": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz", - "integrity": "sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/layout": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz", - "integrity": "sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/position": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz", - "integrity": "sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/shadow": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz", - "integrity": "sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/space": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz", - "integrity": "sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/typography": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz", - "integrity": "sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg==", - "requires": { - "@styled-system/core": "^5.1.2" - } - }, - "@styled-system/variant": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz", - "integrity": "sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw==", - "requires": { - "@styled-system/core": "^5.1.2", - "@styled-system/css": "^5.1.5" - } - }, - "@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "requires": { - "tslib": "^2.8.0" - } - }, - "@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "requires": { - "defer-to-connect": "^2.0.0" - } - }, - "@tailwindcss/node": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz", - "integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==", - "dev": true, - "requires": { - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.29.2", - "tailwindcss": "4.1.5" - } - }, - "@tailwindcss/oxide": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz", - "integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==", - "dev": true, - "requires": { - "@tailwindcss/oxide-android-arm64": "4.1.5", - "@tailwindcss/oxide-darwin-arm64": "4.1.5", - "@tailwindcss/oxide-darwin-x64": "4.1.5", - "@tailwindcss/oxide-freebsd-x64": "4.1.5", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", - "@tailwindcss/oxide-linux-x64-musl": "4.1.5", - "@tailwindcss/oxide-wasm32-wasi": "4.1.5", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" - } - }, - "@tailwindcss/oxide-android-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz", - "integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz", - "integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-darwin-x64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz", - "integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz", - "integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz", - "integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz", - "integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz", - "integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz", - "integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz", - "integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz", - "integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==", - "dev": true, - "optional": true, - "requires": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - } - }, - "@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz", - "integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==", - "dev": true, - "optional": true - }, - "@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz", - "integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==", - "dev": true, - "optional": true - }, - "@tailwindcss/postcss": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.5.tgz", - "integrity": "sha512-5lAC2/pzuyfhsFgk6I58HcNy6vPK3dV/PoPxSDuOTVbDvCddYHzHiJZZInGIY0venvzzfrTEUAXJFULAfFmObg==", - "dev": true, - "requires": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.5", - "@tailwindcss/oxide": "4.1.5", - "postcss": "^8.4.41", - "tailwindcss": "4.1.5" - } - }, - "@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "optional": true, - "requires": { - "tslib": "^2.4.0" - } - }, - "@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "requires": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true - }, - "@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" - }, - "@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 - }, - "@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "requires": { - "@types/node": "*" - } - }, - "@types/node": { - "version": "22.15.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz", - "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==", - "requires": { - "undici-types": "~6.21.0" - } - }, - "@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "requires": { - "@types/node": "*" - } - }, - "@types/stylis": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", - "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" - }, - "@typescript-eslint/eslint-plugin": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", - "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", - "dev": true, - "requires": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/type-utils": "8.32.0", - "@typescript-eslint/utils": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - } - }, - "@typescript-eslint/parser": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", - "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", - "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", - "dev": true, - "requires": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.0" - } - }, - "@typescript-eslint/type-utils": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", - "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", - "dev": true, - "requires": { - "@typescript-eslint/typescript-estree": "8.32.0", - "@typescript-eslint/utils": "8.32.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - } - }, - "@typescript-eslint/types": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", - "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", - "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", - "dev": true, - "requires": { - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/visitor-keys": "8.32.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" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "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, - "requires": { - "@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" - } - }, - "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, - "requires": { - "is-glob": "^4.0.1" - } - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } - } - }, - "@typescript-eslint/utils": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", - "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.0", - "@typescript-eslint/types": "8.32.0", - "@typescript-eslint/typescript-estree": "8.32.0" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "8.32.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", - "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", - "dev": true, - "requires": { - "@typescript-eslint/types": "8.32.0", - "eslint-visitor-keys": "^4.2.0" - } - }, - "@unrs/resolver-binding-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-vxtBno4xvowwNmO/ASL0Y45TpHqmNkAaDtz4Jqb+clmcVSSl8XCG/PNFFkGsXXXS6AMjP+ja/TtNCFFa1QwLRg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qhVa8ozu92C23Hsmv0BF4+5Dyyd5STT1FolV4whNgbY6mj3kA0qsrGPe35zNR3wAN7eFict3s4Rc2dDTPBTuFQ==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-freebsd-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.7.2.tgz", - "integrity": "sha512-zKKdm2uMXqLFX6Ac7K5ElnnG5VIXbDlFWzg4WJ8CGUedJryM5A3cTgHuGMw1+P5ziV8CRhnSEgOnurTI4vpHpg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-8N1z1TbPnHH+iDS/42GJ0bMPLiGK+cUqOhNbMKtWJ4oFGzqSJk/zoXFzcQkgtI63qMcUI7wW1tq2usZQSb2jxw==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.7.2.tgz", - "integrity": "sha512-tjYzI9LcAXR9MYd9rO45m1s0B/6bJNuZ6jeOxo1pq1K6OBuRMMmfyvJYval3s9FPPGmrldYA3mi4gWDlWuTFGA==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-jon9M7DKRLGZ9VYSkFMflvNqu9hDtOCEnO2QAryFWgT6o6AXU8du56V7YqnaLKr6rAbZBWYsYpikF226v423QA==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-c8Cg4/h+kQ63pL43wBNaVMmOjXI/X62wQmru51qjfTvI7kmCy5uHTJvK/9LrF0G8Jdx8r34d019P1DVJmhXQpA==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.7.2.tgz", - "integrity": "sha512-A+lcwRFyrjeJmv3JJvhz5NbcCkLQL6Mk16kHTNm6/aGNc4FwPHPE4DR9DwuCvCnVHvF5IAd9U4VIs/VvVir5lg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.7.2.tgz", - "integrity": "sha512-hQQ4TJQrSQW8JlPm7tRpXN8OCNP9ez7PajJNjRD1ZTHQAy685OYqPrKjfaMw/8LiHCt8AZ74rfUVHP9vn0N69Q==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.7.2.tgz", - "integrity": "sha512-NoAGbiqrxtY8kVooZ24i70CjLDlUFI7nDj3I9y54U94p+3kPxwd2L692YsdLa+cqQ0VoqMWoehDFp21PKRUoIQ==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.7.2.tgz", - "integrity": "sha512-KaZByo8xuQZbUhhreBTW+yUnOIHUsv04P8lKjQ5otiGoSJ17ISGYArc+4vKdLEpGaLbemGzr4ZeUbYQQsLWFjA==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-dEidzJDubxxhUCBJ/SHSMJD/9q7JkyfBMT77Px1npl4xpg9t0POLvnWywSk66BgZS/b2Hy9Y1yFaoMTFJUe9yg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-RvP+Ux3wDjmnZDT4XWFfNBRVG0fMsc+yVzNFUqOflnDfZ9OYujv6nkh+GOr+watwrW4wdp6ASfG/e7bkDradsw==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-wasm32-wasi": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.7.2.tgz", - "integrity": "sha512-y797JBmO9IsvXVRCKDXOxjyAE4+CcZpla2GSoBQ33TVb3ILXuFnMrbR/QQZoauBYeOFuu4w3ifWLw52sdHGz6g==", - "dev": true, - "optional": true, - "requires": { - "@napi-rs/wasm-runtime": "^0.2.9" - } - }, - "@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-gtYTh4/VREVSLA+gHrfbWxaMO/00y+34htY7XpioBTy56YN2eBjkPrY1ML1Zys89X3RJDKVaogzwxlM1qU7egg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-Ywv20XHvHTDRQs12jd3MY8X5C8KLjDbg/jyaal/QLKx3fAShhJyD4blEANInsjxW3P7isHx1Blt56iUDDJO3jg==", - "dev": true, - "optional": true - }, - "@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-friS8NEQfHaDbkThxopGk+LuE5v3iY0StruifjQEt7SLbA46OnfgMO15sOTkbpJkol6RB+1l1TYPXh0sCddpvA==", - "dev": true, - "optional": true - }, - "ably": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/ably/-/ably-2.12.0.tgz", - "integrity": "sha512-sU2LSpXXxesYv+CbX/p/+w0dHUkn8+V92iusZVvhQBQpzUbENGvjsVlt5gph8Q/FzdY67lwTMFYQ6yo5od2SJw==", - "requires": { - "@ably/msgpack-js": "^0.4.0", - "dequal": "^2.0.3", - "fastestsmallesttextencoderdecoder": "^1.0.22", - "got": "^11.8.5", - "ulid": "^2.3.0", - "ws": "^8.17.1" - } - }, - "accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "requires": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - } - }, - "acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true - }, - "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, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "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, - "requires": { - "color-convert": "^2.0.1" - } - }, - "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 - }, - "aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true - }, - "array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - } - }, - "array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - } - }, - "array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - } - }, - "array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - } - }, - "array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - } - }, - "array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - } - }, - "array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - } - }, - "arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "requires": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - } - }, - "ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true - }, - "async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true - }, - "available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "requires": { - "possible-typed-array-names": "^1.0.0" - } - }, - "axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", - "dev": true - }, - "axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true - }, - "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 - }, - "base64-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.2.tgz", - "integrity": "sha512-ZXBDPMt/v/8fsIqn+Z5VwrhdR6jVka0bYobHdGia0Nxi7BJ9i/Uvml3AocHIBtIIBhZjBw5MR0aR4ROs/8+SNg==" - }, - "body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "requires": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - } - }, - "bops": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bops/-/bops-1.0.1.tgz", - "integrity": "sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==", - "requires": { - "base64-js": "1.0.2", - "to-utf8": "0.0.1" - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "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, - "requires": { - "fill-range": "^7.1.1" - } - }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true - }, - "cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" - }, - "cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "requires": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - } - }, - "call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "requires": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - } - }, - "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==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - } - }, - "call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "requires": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==" - }, - "caniuse-lite": { - "version": "1.0.30001717", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", - "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "requires": { - "mimic-response": "^1.0.0" - } - }, - "color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, - "requires": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - } - }, - "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==", - "devOptional": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "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==", - "devOptional": true - }, - "color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true - }, - "cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true - }, - "cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "requires": { - "object-assign": "^4", - "vary": "^1" - } - }, - "cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==" - }, - "css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "requires": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, - "csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - } - }, - "data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - } - }, - "data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - } - }, - "debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - }, - "decimal.js": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", - "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==" - }, - "decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "requires": { - "mimic-response": "^3.1.0" - }, - "dependencies": { - "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" - } - } - }, - "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 - }, - "defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" - }, - "define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "requires": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - } - }, - "define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "requires": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true - }, - "dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" - }, - "detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true - }, - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "requires": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - } - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, - "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 - }, - "encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "requires": { - "once": "^1.4.0" - } - }, - "enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, - "es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", - "dev": true, - "requires": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-regex": "^1.2.1", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" - } - }, - "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==", - "dev": true - }, - "es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true - }, - "es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" - } - }, - "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==", - "dev": true, - "requires": { - "es-errors": "^1.3.0" - } - }, - "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==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, - "es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "requires": { - "hasown": "^2.0.2" - } - }, - "es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "requires": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - } - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "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 - }, - "eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", - "@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.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.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", - "zod": "^3.24.2" - } - }, - "eslint-config-next": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.2.tgz", - "integrity": "sha512-FerU4DYccO4FgeYFFglz0SnaKRe1ejXQrDb8kWUkTAg036YWi+jUsgg4sIGNCDhAsDITsZaL4MzBWKB6f4G1Dg==", - "dev": true, - "requires": { - "@next/eslint-plugin-next": "15.3.2", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" - } - }, - "eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "requires": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "requires": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - } - }, - "eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", - "dev": true, - "requires": { - "debug": "^3.2.7" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", - "dev": true, - "requires": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", - "hasown": "^2.0.2", - "is-core-module": "^2.15.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.0", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", - "tsconfig-paths": "^3.15.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "requires": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - } - }, - "eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "requires": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "dependencies": { - "resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "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, - "requires": {} - }, - "eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true - }, - "espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "requires": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - } - }, - "esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "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, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true - }, - "eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "requires": { - "eventsource-parser": "^3.0.1" - } - }, - "eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true - }, - "express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "requires": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - } - }, - "express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "requires": {} - }, - "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 - }, - "fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "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, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "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 - }, - "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 - }, - "fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==" - }, - "fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "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, - "requires": { - "flat-cache": "^4.0.0" - } - }, - "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, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "requires": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - } - }, - "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, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "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, - "requires": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - } - }, - "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 - }, - "for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "requires": { - "is-callable": "^1.2.7" - } - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true - }, - "fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true - }, - "function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - } - }, - "functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true - }, - "get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "requires": { - "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" - } - }, - "get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "requires": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "requires": { - "pump": "^3.0.0" - } - }, - "get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - } - }, - "get-tsconfig": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", - "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", - "dev": true, - "requires": { - "resolve-pkg-maps": "^1.0.0" - } - }, - "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, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true - }, - "globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "requires": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - } - }, - "gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true - }, - "got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "requires": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - } - }, - "graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true - }, - "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 - }, - "has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "requires": { - "es-define-property": "^1.0.0" - } - }, - "has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "requires": { - "dunder-proto": "^1.0.0" - } - }, - "has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "requires": { - "has-symbols": "^1.0.3" - } - }, - "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "requires": { - "function-bind": "^1.1.2" - } - }, - "http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "requires": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - } - }, - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true - }, - "immutable": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", - "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==" - }, - "immutable-v4": { - "version": "npm:immutable@4.0.0-rc.12", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", - "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==" - }, - "import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - } - }, - "intl-messageformat": { - "version": "10.7.16", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", - "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", - "requires": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.2", - "tslib": "^2.8.0" - } - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true - }, - "is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - } - }, - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true - }, - "is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "requires": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - } - }, - "is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "requires": { - "has-bigints": "^1.0.2" - } - }, - "is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - } - }, - "is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "requires": { - "semver": "^7.7.1" - } - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true - }, - "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, - "requires": { - "hasown": "^2.0.2" - } - }, - "is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - } - }, - "is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "requires": { - "call-bound": "^1.0.3" - } - }, - "is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - } - }, - "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, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true - }, - "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 - }, - "is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - } - }, - "is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true - }, - "is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, - "is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true - }, - "is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "requires": { - "call-bound": "^1.0.3" - } - }, - "is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - } - }, - "is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - } - }, - "is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "requires": { - "which-typed-array": "^1.1.16" - } - }, - "is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true - }, - "is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "requires": { - "call-bound": "^1.0.3" - } - }, - "is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - } - }, - "isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "requires": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - } - }, - "jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true - }, - "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 - }, - "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, - "requires": { - "argparse": "^2.0.1" - } - }, - "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==" - }, - "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 - }, - "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 - }, - "json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "requires": { - "minimist": "^1.2.0" - } - }, - "jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "requires": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - } - }, - "keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "requires": { - "json-buffer": "3.0.1" - } - }, - "language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true - }, - "language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "requires": { - "language-subtag-registry": "^0.3.20" - } - }, - "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, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lightningcss": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", - "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", - "dev": true, - "requires": { - "detect-libc": "^2.0.3", - "lightningcss-darwin-arm64": "1.29.2", - "lightningcss-darwin-x64": "1.29.2", - "lightningcss-freebsd-x64": "1.29.2", - "lightningcss-linux-arm-gnueabihf": "1.29.2", - "lightningcss-linux-arm64-gnu": "1.29.2", - "lightningcss-linux-arm64-musl": "1.29.2", - "lightningcss-linux-x64-gnu": "1.29.2", - "lightningcss-linux-x64-musl": "1.29.2", - "lightningcss-win32-arm64-msvc": "1.29.2", - "lightningcss-win32-x64-msvc": "1.29.2" - } - }, - "lightningcss-darwin-arm64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", - "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", - "dev": true, - "optional": true - }, - "lightningcss-darwin-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", - "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", - "dev": true, - "optional": true - }, - "lightningcss-freebsd-x64": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", - "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", - "dev": true, - "optional": true - }, - "lightningcss-linux-arm-gnueabihf": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", - "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", - "dev": true, - "optional": true - }, - "lightningcss-linux-arm64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", - "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", - "dev": true, - "optional": true - }, - "lightningcss-linux-arm64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", - "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", - "dev": true, - "optional": true - }, - "lightningcss-linux-x64-gnu": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", - "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", - "dev": true, - "optional": true - }, - "lightningcss-linux-x64-musl": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", - "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", - "dev": true, - "optional": true - }, - "lightningcss-win32-arm64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", - "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", - "dev": true, - "optional": true - }, - "lightningcss-win32-x64-msvc": { - "version": "1.29.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", - "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", - "dev": true, - "optional": true - }, - "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, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "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 - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" - }, - "math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true - }, - "media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true - }, - "merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "requires": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - } - }, - "mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true - }, - "mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "requires": { - "mime-db": "^1.54.0" - } - }, - "mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true - }, - "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 - }, - "nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" - }, - "napi-postinstall": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.3.tgz", - "integrity": "sha512-Mi7JISo/4Ij2tDZ2xBE2WH+/KvVlkhA6juEjpEeRAVPNCpN3nxJo/5FhDNKgBcdmcmhaH6JjgST4xY/23ZYK0w==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" - }, - "next": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.2.tgz", - "integrity": "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==", - "requires": { - "@next/env": "15.3.2", - "@next/swc-darwin-arm64": "15.3.2", - "@next/swc-darwin-x64": "15.3.2", - "@next/swc-linux-arm64-gnu": "15.3.2", - "@next/swc-linux-arm64-musl": "15.3.2", - "@next/swc-linux-x64-gnu": "15.3.2", - "@next/swc-linux-x64-musl": "15.3.2", - "@next/swc-win32-arm64-msvc": "15.3.2", - "@next/swc-win32-x64-msvc": "15.3.2", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.15", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "sharp": "^0.34.1", - "styled-jsx": "5.1.6" - }, - "dependencies": { - "postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "requires": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - } - } - }, - "next-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.1.0.tgz", - "integrity": "sha512-JNJRjc7sdnfUxhZmGcvzDszZ60tQKrygV/VLsgzXhnJDxQPn1cN2rVpc53adA1SvBJwPK2O6Sc6b4gYSILjCzw==", - "requires": { - "@formatjs/intl-localematcher": "^0.5.4", - "negotiator": "^1.0.0", - "use-intl": "^4.1.0" - } - }, - "normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" - }, - "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==" - }, - "object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - } - }, - "object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - } - }, - "object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - } - }, - "object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - } - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "requires": { - "ee-first": "1.1.1" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "requires": { - "wrappy": "1" - } - }, - "optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "requires": { - "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" - } - }, - "own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "requires": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - } - }, - "p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" - }, - "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, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "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, - "requires": { - "p-limit": "^3.0.2" - } - }, - "pako": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", - "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==" - }, - "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, - "requires": { - "callsites": "^3.0.0" - } - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true - }, - "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 - }, - "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 - }, - "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 - }, - "path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true - }, - "picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true - }, - "polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "requires": { - "@babel/runtime": "^7.17.8" - } - }, - "possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true - }, - "postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", - "dev": true, - "requires": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - } - }, - "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==" - }, - "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 - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true - }, - "qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "requires": { - "side-channel": "^1.1.0" - } - }, - "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 - }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true - }, - "raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - } - }, - "react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==" - }, - "react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "requires": { - "scheduler": "^0.26.0" - } - }, - "react-from-dom": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/react-from-dom/-/react-from-dom-0.7.5.tgz", - "integrity": "sha512-CO92PmMKo/23uYPm6OFvh5CtZbMgHs/Xn+o095Lz/TZj9t8DSDhGdSOMLxBxwWI4sr0MF17KUn9yJWc5Q00R/w==", - "requires": {} - }, - "react-inlinesvg": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/react-inlinesvg/-/react-inlinesvg-4.2.0.tgz", - "integrity": "sha512-V59P6sFU7NACIbvoay9ikYKVFWyIIZFGd7w6YT1m+H7Ues0fOI6B6IftE6NPSYXXv7RHVmrncIyJeYurs3OJcA==", - "requires": { - "react-from-dom": "^0.7.5" - } - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - } - }, - "regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - } - }, - "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, - "requires": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" - }, - "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 - }, - "resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true - }, - "responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "requires": { - "lowercase-keys": "^2.0.0" - } - }, - "reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true - }, - "router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "requires": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - } - }, - "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, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "requires": { - "tslib": "^2.1.0" - } - }, - "safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - } - }, - "safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" - }, - "semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "devOptional": true - }, - "send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "requires": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - } - }, - "serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "requires": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - } - }, - "set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "requires": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - } - }, - "set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "requires": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - } - }, - "set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "requires": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - } - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, - "shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" - }, - "sharp": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", - "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", - "optional": true, - "requires": { - "@img/sharp-darwin-arm64": "0.34.1", - "@img/sharp-darwin-x64": "0.34.1", - "@img/sharp-libvips-darwin-arm64": "1.1.0", - "@img/sharp-libvips-darwin-x64": "1.1.0", - "@img/sharp-libvips-linux-arm": "1.1.0", - "@img/sharp-libvips-linux-arm64": "1.1.0", - "@img/sharp-libvips-linux-ppc64": "1.1.0", - "@img/sharp-libvips-linux-s390x": "1.1.0", - "@img/sharp-libvips-linux-x64": "1.1.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", - "@img/sharp-libvips-linuxmusl-x64": "1.1.0", - "@img/sharp-linux-arm": "0.34.1", - "@img/sharp-linux-arm64": "0.34.1", - "@img/sharp-linux-s390x": "0.34.1", - "@img/sharp-linux-x64": "0.34.1", - "@img/sharp-linuxmusl-arm64": "0.34.1", - "@img/sharp-linuxmusl-x64": "0.34.1", - "@img/sharp-wasm32": "0.34.1", - "@img/sharp-win32-ia32": "0.34.1", - "@img/sharp-win32-x64": "0.34.1", - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.7.1" - } - }, - "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, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "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 - }, - "shortid": { - "version": "2.2.17", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.17.tgz", - "integrity": "sha512-GpbM3gLF1UUXZvQw6MCyulHkWbRseNO4cyBEZresZRorwl1+SLu1ZdqgVtuwqz8mB6RpwPkm541mYSqrKyJSaA==", - "requires": { - "nanoid": "^3.3.8" - } - }, - "side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - } - }, - "side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - } - }, - "side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - } - }, - "side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - } - }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, - "requires": { - "is-arrayish": "^0.3.1" - } - }, - "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==" - }, - "stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, - "string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - } - }, - "string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - } - }, - "string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - } - }, - "string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - } - }, - "string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true - }, - "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 - }, - "styled-components": { - "version": "6.1.18", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.18.tgz", - "integrity": "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw==", - "requires": { - "@emotion/is-prop-valid": "1.2.2", - "@emotion/unitless": "0.8.1", - "@types/stylis": "4.2.5", - "css-to-react-native": "3.2.0", - "csstype": "3.1.3", - "postcss": "8.4.49", - "shallowequal": "1.1.0", - "stylis": "4.3.2", - "tslib": "2.6.2" - }, - "dependencies": { - "postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "requires": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - } - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - } - } - }, - "styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "requires": { - "client-only": "0.0.1" - } - }, - "styled-system": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz", - "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==", - "requires": { - "@styled-system/background": "^5.1.2", - "@styled-system/border": "^5.1.5", - "@styled-system/color": "^5.1.2", - "@styled-system/core": "^5.1.2", - "@styled-system/flexbox": "^5.1.2", - "@styled-system/grid": "^5.1.2", - "@styled-system/layout": "^5.1.2", - "@styled-system/position": "^5.1.2", - "@styled-system/shadow": "^5.1.2", - "@styled-system/space": "^5.1.2", - "@styled-system/typography": "^5.1.2", - "@styled-system/variant": "^5.1.5", - "object-assign": "^4.1.1" - } - }, - "stylis": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", - "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" - }, - "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, - "requires": { - "has-flag": "^4.0.0" - } - }, - "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 - }, - "tailwindcss": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz", - "integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==", - "dev": true - }, - "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true - }, - "tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", - "dev": true, - "requires": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "dependencies": { - "fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "requires": {} - }, - "picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true - } - } - }, - "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, - "requires": { - "is-number": "^7.0.0" - } - }, - "to-utf8": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz", - "integrity": "sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ==" - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true - }, - "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, - "requires": {} - }, - "tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "requires": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "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, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "requires": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - } - }, - "typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - } - }, - "typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "requires": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - } - }, - "typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - } - }, - "typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - } - }, - "typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, - "peer": true - }, - "ulid": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", - "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==" - }, - "unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "requires": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - } - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true - }, - "unrs-resolver": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.7.2.tgz", - "integrity": "sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==", - "dev": true, - "requires": { - "@unrs/resolver-binding-darwin-arm64": "1.7.2", - "@unrs/resolver-binding-darwin-x64": "1.7.2", - "@unrs/resolver-binding-freebsd-x64": "1.7.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.7.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.7.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.7.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.7.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.7.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.7.2", - "@unrs/resolver-binding-linux-x64-musl": "1.7.2", - "@unrs/resolver-binding-wasm32-wasi": "1.7.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.7.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.7.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.7.2", - "napi-postinstall": "^0.2.2" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "use-intl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.1.0.tgz", - "integrity": "sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==", - "requires": { - "@formatjs/fast-memoize": "^2.2.0", - "@schummar/icu-type-parser": "1.21.5", - "intl-messageformat": "^10.5.14" - } - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "requires": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - } - }, - "which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "requires": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - } - }, - "which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "requires": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - } - }, - "which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "requires": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - } - }, - "word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "requires": {} - }, - "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 - }, - "zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true - }, - "zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "requires": {} } } } diff --git a/web/ably_chat/package.json b/web/ably_chat/package.json index b0464b5..a87fa4b 100644 --- a/web/ably_chat/package.json +++ b/web/ably_chat/package.json @@ -1,5 +1,5 @@ { - "name": "ably-testing", + "name": "chatroom-17live", "version": "0.1.0", "private": true, "scripts": { @@ -9,27 +9,26 @@ "lint": "next lint" }, "dependencies": { + "@types/styled-components": "^5.1.35", "ably": "^2.12.0", + "axios": "^1.13.2", "immutable": "^3.8.2", - "immutable-v4": "npm:immutable@4.0.0-rc.12", "lodash": "^4.17.21", + "lucide-react": "^0.553.0", + "nanoid": "^5.0.7", "next": "15.3.2", "next-intl": "^4.1.0", "pako": "^1.0.6", - "polished": "^4.3.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-inlinesvg": "^4.2.0", "rxjs": "^7.8.2", - "shortid": "^2.2.17", - "styled-components": "^6.1.18", - "styled-system": "^5.1.5" + "styled-components": "^6.1.19", + "tmi.js": "^1.8.5" }, "devDependencies": { "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", "eslint": "^9", - "eslint-config-next": "15.3.2", - "tailwindcss": "^4" + "eslint-config-next": "15.3.2" } } diff --git a/web/ably_chat/postcss.config.mjs b/web/ably_chat/postcss.config.mjs index c7bcb4b..5f0fd95 100644 --- a/web/ably_chat/postcss.config.mjs +++ b/web/ably_chat/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: ["@tailwindcss/postcss"], + plugins: [], }; export default config; diff --git a/web/ably_chat/public/images/17live.svg b/web/ably_chat/public/images/17live.svg new file mode 100644 index 0000000..f710dd5 --- /dev/null +++ b/web/ably_chat/public/images/17live.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/ably_chat/public/images/chat.svg b/web/ably_chat/public/images/chat.svg new file mode 100644 index 0000000..412d9ff --- /dev/null +++ b/web/ably_chat/public/images/chat.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/web/ably_chat/public/images/twitch.svg b/web/ably_chat/public/images/twitch.svg new file mode 100644 index 0000000..1922670 --- /dev/null +++ b/web/ably_chat/public/images/twitch.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/web/ably_chat/public/images/youtube.svg b/web/ably_chat/public/images/youtube.svg new file mode 100644 index 0000000..f41196e --- /dev/null +++ b/web/ably_chat/public/images/youtube.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/web/ably_chat/public/index.html b/web/ably_chat/public/index.html new file mode 100644 index 0000000..e69de29 diff --git a/web/ably_chat/public/vff/assets/index-CJAZd0Lk.js b/web/ably_chat/public/vff/assets/index-CJAZd0Lk.js new file mode 100644 index 0000000..7c47eb7 --- /dev/null +++ b/web/ably_chat/public/vff/assets/index-CJAZd0Lk.js @@ -0,0 +1,114 @@ +(function(){const d=document.createElement("link").relList;if(d&&d.supports&&d.supports("modulepreload"))return;for(const z of document.querySelectorAll('link[rel="modulepreload"]'))f(z);new MutationObserver(z=>{for(const D of z)if(D.type==="childList")for(const L of D.addedNodes)L.tagName==="LINK"&&L.rel==="modulepreload"&&f(L)}).observe(document,{childList:!0,subtree:!0});function S(z){const D={};return z.integrity&&(D.integrity=z.integrity),z.referrerPolicy&&(D.referrerPolicy=z.referrerPolicy),z.crossOrigin==="use-credentials"?D.credentials="include":z.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function f(z){if(z.ep)return;z.ep=!0;const D=S(z);fetch(z.href,D)}})();var kc={exports:{}},mu={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Ir;function Cv(){if(Ir)return mu;Ir=1;var E=Symbol.for("react.transitional.element"),d=Symbol.for("react.fragment");function S(f,z,D){var L=null;if(D!==void 0&&(L=""+D),z.key!==void 0&&(L=""+z.key),"key"in z){D={};for(var q in z)q!=="key"&&(D[q]=z[q])}else D=z;return z=D.ref,{$$typeof:E,type:f,key:L,ref:z!==void 0?z:null,props:D}}return mu.Fragment=d,mu.jsx=S,mu.jsxs=S,mu}var ld;function Yv(){return ld||(ld=1,kc.exports=Cv()),kc.exports}var Se=Yv(),Pc={exports:{}},V={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var td;function Gv(){if(td)return V;td=1;var E=Symbol.for("react.transitional.element"),d=Symbol.for("react.portal"),S=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),z=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),L=Symbol.for("react.context"),q=Symbol.for("react.forward_ref"),R=Symbol.for("react.suspense"),b=Symbol.for("react.memo"),M=Symbol.for("react.lazy"),j=Symbol.iterator;function W(o){return o===null||typeof o!="object"?null:(o=j&&o[j]||o["@@iterator"],typeof o=="function"?o:null)}var K={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ml=Object.assign,ol={};function al(o,O,N){this.props=o,this.context=O,this.refs=ol,this.updater=N||K}al.prototype.isReactComponent={},al.prototype.setState=function(o,O){if(typeof o!="object"&&typeof o!="function"&&o!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,o,O,"setState")},al.prototype.forceUpdate=function(o){this.updater.enqueueForceUpdate(this,o,"forceUpdate")};function gl(){}gl.prototype=al.prototype;function xl(o,O,N){this.props=o,this.context=O,this.refs=ol,this.updater=N||K}var dl=xl.prototype=new gl;dl.constructor=xl,ml(dl,al.prototype),dl.isPureReactComponent=!0;var Bl=Array.isArray,F={H:null,A:null,T:null,S:null,V:null},Ol=Object.prototype.hasOwnProperty;function Ll(o,O,N,U,C,I){return N=I.ref,{$$typeof:E,type:o,key:O,ref:N!==void 0?N:null,props:I}}function jl(o,O){return Ll(o.type,O,void 0,void 0,void 0,o.props)}function nt(o){return typeof o=="object"&&o!==null&&o.$$typeof===E}function Lt(o){var O={"=":"=0",":":"=2"};return"$"+o.replace(/[=:]/g,function(N){return O[N]})}var zt=/\/+/g;function Cl(o,O){return typeof o=="object"&&o!==null&&o.key!=null?Lt(""+o.key):O.toString(36)}function ha(){}function va(o){switch(o.status){case"fulfilled":return o.value;case"rejected":throw o.reason;default:switch(typeof o.status=="string"?o.then(ha,ha):(o.status="pending",o.then(function(O){o.status==="pending"&&(o.status="fulfilled",o.value=O)},function(O){o.status==="pending"&&(o.status="rejected",o.reason=O)})),o.status){case"fulfilled":return o.value;case"rejected":throw o.reason}}throw o}function Yl(o,O,N,U,C){var I=typeof o;(I==="undefined"||I==="boolean")&&(o=null);var Z=!1;if(o===null)Z=!0;else switch(I){case"bigint":case"string":case"number":Z=!0;break;case"object":switch(o.$$typeof){case E:case d:Z=!0;break;case M:return Z=o._init,Yl(Z(o._payload),O,N,U,C)}}if(Z)return C=C(o),Z=U===""?"."+Cl(o,0):U,Bl(C)?(N="",Z!=null&&(N=Z.replace(zt,"$&/")+"/"),Yl(C,O,N,"",function(jt){return jt})):C!=null&&(nt(C)&&(C=jl(C,N+(C.key==null||o&&o.key===C.key?"":(""+C.key).replace(zt,"$&/")+"/")+Z)),O.push(C)),1;Z=0;var Fl=U===""?".":U+":";if(Bl(o))for(var hl=0;hl>>1,o=A[fl];if(0>>1;flz(U,X))Cz(I,U)?(A[fl]=I,A[C]=X,fl=C):(A[fl]=U,A[N]=X,fl=N);else if(Cz(I,X))A[fl]=I,A[C]=X,fl=C;else break l}}return x}function z(A,x){var X=A.sortIndex-x.sortIndex;return X!==0?X:A.id-x.id}if(E.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var D=performance;E.unstable_now=function(){return D.now()}}else{var L=Date,q=L.now();E.unstable_now=function(){return L.now()-q}}var R=[],b=[],M=1,j=null,W=3,K=!1,ml=!1,ol=!1,al=!1,gl=typeof setTimeout=="function"?setTimeout:null,xl=typeof clearTimeout=="function"?clearTimeout:null,dl=typeof setImmediate<"u"?setImmediate:null;function Bl(A){for(var x=S(b);x!==null;){if(x.callback===null)f(b);else if(x.startTime<=A)f(b),x.sortIndex=x.expirationTime,d(R,x);else break;x=S(b)}}function F(A){if(ol=!1,Bl(A),!ml)if(S(R)!==null)ml=!0,Ol||(Ol=!0,Cl());else{var x=S(b);x!==null&&Yl(F,x.startTime-A)}}var Ol=!1,Ll=-1,jl=5,nt=-1;function Lt(){return al?!0:!(E.unstable_now()-ntA&&Lt());){var fl=j.callback;if(typeof fl=="function"){j.callback=null,W=j.priorityLevel;var o=fl(j.expirationTime<=A);if(A=E.unstable_now(),typeof o=="function"){j.callback=o,Bl(A),x=!0;break t}j===S(R)&&f(R),Bl(A)}else f(R);j=S(R)}if(j!==null)x=!0;else{var O=S(b);O!==null&&Yl(F,O.startTime-A),x=!1}}break l}finally{j=null,W=X,K=!1}x=void 0}}finally{x?Cl():Ol=!1}}}var Cl;if(typeof dl=="function")Cl=function(){dl(zt)};else if(typeof MessageChannel<"u"){var ha=new MessageChannel,va=ha.port2;ha.port1.onmessage=zt,Cl=function(){va.postMessage(null)}}else Cl=function(){gl(zt,0)};function Yl(A,x){Ll=gl(function(){A(E.unstable_now())},x)}E.unstable_IdlePriority=5,E.unstable_ImmediatePriority=1,E.unstable_LowPriority=4,E.unstable_NormalPriority=3,E.unstable_Profiling=null,E.unstable_UserBlockingPriority=2,E.unstable_cancelCallback=function(A){A.callback=null},E.unstable_forceFrameRate=function(A){0>A||125fl?(A.sortIndex=X,d(b,A),S(R)===null&&A===S(b)&&(ol?(xl(Ll),Ll=-1):ol=!0,Yl(F,X-fl))):(A.sortIndex=o,d(R,A),ml||K||(ml=!0,Ol||(Ol=!0,Cl()))),A},E.unstable_shouldYield=Lt,E.unstable_wrapCallback=function(A){var x=W;return function(){var X=W;W=x;try{return A.apply(this,arguments)}finally{W=X}}}})(tf)),tf}var ud;function Qv(){return ud||(ud=1,lf.exports=Xv()),lf.exports}var af={exports:{}},Ql={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var nd;function Lv(){if(nd)return Ql;nd=1;var E=ef();function d(R){var b="https://react.dev/errors/"+R;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(E)}catch(d){console.error(d)}}return E(),af.exports=Lv(),af.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var cd;function Zv(){if(cd)return gu;cd=1;var E=Qv(),d=ef(),S=jv();function f(l){var t="https://react.dev/errors/"+l;if(1o||(l.current=fl[o],fl[o]=null,o--)}function U(l,t){o++,fl[o]=l.current,l.current=t}var C=O(null),I=O(null),Z=O(null),Fl=O(null);function hl(l,t){switch(U(Z,t),U(I,l),U(C,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?Dr(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=Dr(t),l=Rr(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}N(C),U(C,l)}function jt(){N(C),N(I),N(Z)}function Cn(l){l.memoizedState!==null&&U(Fl,l);var t=C.current,a=Rr(t,l.type);t!==a&&(U(I,l),U(C,a))}function bu(l){I.current===l&&(N(C),N(I)),Fl.current===l&&(N(Fl),ru._currentValue=X)}var Yn=Object.prototype.hasOwnProperty,Gn=E.unstable_scheduleCallback,Xn=E.unstable_cancelCallback,vd=E.unstable_shouldYield,yd=E.unstable_requestPaint,bt=E.unstable_now,md=E.unstable_getCurrentPriorityLevel,nf=E.unstable_ImmediatePriority,cf=E.unstable_UserBlockingPriority,Tu=E.unstable_NormalPriority,gd=E.unstable_LowPriority,ff=E.unstable_IdlePriority,Sd=E.log,bd=E.unstable_setDisableYieldValue,be=null,$l=null;function Zt(l){if(typeof Sd=="function"&&bd(l),$l&&typeof $l.setStrictMode=="function")try{$l.setStrictMode(be,l)}catch{}}var kl=Math.clz32?Math.clz32:Ad,Td=Math.log,Ed=Math.LN2;function Ad(l){return l>>>=0,l===0?32:31-(Td(l)/Ed|0)|0}var Eu=256,Au=4194304;function ya(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function _u(l,t,a){var e=l.pendingLanes;if(e===0)return 0;var u=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=e&134217727;return c!==0?(e=c&~n,e!==0?u=ya(e):(i&=c,i!==0?u=ya(i):a||(a=c&~l,a!==0&&(u=ya(a))))):(c=e&~n,c!==0?u=ya(c):i!==0?u=ya(i):a||(a=e&~l,a!==0&&(u=ya(a)))),u===0?0:t!==0&&t!==u&&(t&n)===0&&(n=u&-u,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:u}function Te(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function _d(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function sf(){var l=Eu;return Eu<<=1,(Eu&4194048)===0&&(Eu=256),l}function of(){var l=Au;return Au<<=1,(Au&62914560)===0&&(Au=4194304),l}function Qn(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function Ee(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function pd(l,t,a,e,u,n){var i=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var c=l.entanglements,s=l.expirationTimes,y=l.hiddenUpdates;for(a=i&~a;0)":-1u||s[e]!==y[u]){var T=` +`+s[e].replace(" at new "," at ");return l.displayName&&T.includes("")&&(T=T.replace("",l.displayName)),T}while(1<=e&&0<=u);break}}}finally{Jn=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?Ga(a):""}function Ud(l){switch(l.tag){case 26:case 27:case 5:return Ga(l.type);case 16:return Ga("Lazy");case 13:return Ga("Suspense");case 19:return Ga("SuspenseList");case 0:case 15:return wn(l.type,!1);case 11:return wn(l.type.render,!1);case 1:return wn(l.type,!0);case 31:return Ga("Activity");default:return""}}function Tf(l){try{var t="";do t+=Ud(l),l=l.return;while(l);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}function it(l){switch(typeof l){case"bigint":case"boolean":case"number":case"string":case"undefined":return l;case"object":return l;default:return""}}function Ef(l){var t=l.type;return(l=l.nodeName)&&l.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function xd(l){var t=Ef(l)?"checked":"value",a=Object.getOwnPropertyDescriptor(l.constructor.prototype,t),e=""+l[t];if(!l.hasOwnProperty(t)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var u=a.get,n=a.set;return Object.defineProperty(l,t,{configurable:!0,get:function(){return u.call(this)},set:function(i){e=""+i,n.call(this,i)}}),Object.defineProperty(l,t,{enumerable:a.enumerable}),{getValue:function(){return e},setValue:function(i){e=""+i},stopTracking:function(){l._valueTracker=null,delete l[t]}}}}function Ou(l){l._valueTracker||(l._valueTracker=xd(l))}function Af(l){if(!l)return!1;var t=l._valueTracker;if(!t)return!0;var a=t.getValue(),e="";return l&&(e=Ef(l)?l.checked?"true":"false":l.value),l=e,l!==a?(t.setValue(l),!0):!1}function Du(l){if(l=l||(typeof document<"u"?document:void 0),typeof l>"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var Nd=/[\n"\\]/g;function ct(l){return l.replace(Nd,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Wn(l,t,a,e,u,n,i,c){l.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.type=i:l.removeAttribute("type"),t!=null?i==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+it(t)):l.value!==""+it(t)&&(l.value=""+it(t)):i!=="submit"&&i!=="reset"||l.removeAttribute("value"),t!=null?Fn(l,i,it(t)):a!=null?Fn(l,i,it(a)):e!=null&&l.removeAttribute("value"),u==null&&n!=null&&(l.defaultChecked=!!n),u!=null&&(l.checked=u&&typeof u!="function"&&typeof u!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.name=""+it(c):l.removeAttribute("name")}function _f(l,t,a,e,u,n,i,c){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||a!=null){if(!(n!=="submit"&&n!=="reset"||t!=null))return;a=a!=null?""+it(a):"",t=t!=null?""+it(t):a,c||t===l.value||(l.value=t),l.defaultValue=t}e=e??u,e=typeof e!="function"&&typeof e!="symbol"&&!!e,l.checked=c?l.checked:!!e,l.defaultChecked=!!e,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(l.name=i)}function Fn(l,t,a){t==="number"&&Du(l.ownerDocument)===l||l.defaultValue===""+a||(l.defaultValue=""+a)}function Xa(l,t,a,e){if(l=l.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),li=!1;if(Dt)try{var ze={};Object.defineProperty(ze,"passive",{get:function(){li=!0}}),window.addEventListener("test",ze,ze),window.removeEventListener("test",ze,ze)}catch{li=!1}var Kt=null,ti=null,Mu=null;function Uf(){if(Mu)return Mu;var l,t=ti,a=t.length,e,u="value"in Kt?Kt.value:Kt.textContent,n=u.length;for(l=0;l=Re),Cf=" ",Yf=!1;function Gf(l,t){switch(l){case"keyup":return ih.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Xf(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Za=!1;function fh(l,t){switch(l){case"compositionend":return Xf(t);case"keypress":return t.which!==32?null:(Yf=!0,Cf);case"textInput":return l=t.data,l===Cf&&Yf?null:l;default:return null}}function sh(l,t){if(Za)return l==="compositionend"||!ii&&Gf(l,t)?(l=Uf(),Mu=ti=Kt=null,Za=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-l};l=e}l:{for(;a;){if(a.nextSibling){a=a.nextSibling;break l}a=a.parentNode}a=void 0}a=wf(a)}}function Ff(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?Ff(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function $f(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Du(l.document);t instanceof l.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)l=t.contentWindow;else break;t=Du(l.document)}return t}function si(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var gh=Dt&&"documentMode"in document&&11>=document.documentMode,Va=null,oi=null,Ne=null,ri=!1;function kf(l,t,a){var e=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;ri||Va==null||Va!==Du(e)||(e=Va,"selectionStart"in e&&si(e)?e={start:e.selectionStart,end:e.selectionEnd}:(e=(e.ownerDocument&&e.ownerDocument.defaultView||window).getSelection(),e={anchorNode:e.anchorNode,anchorOffset:e.anchorOffset,focusNode:e.focusNode,focusOffset:e.focusOffset}),Ne&&xe(Ne,e)||(Ne=e,e=Tn(oi,"onSelect"),0>=i,u-=i,Mt=1<<32-kl(t)+u|a<n?n:8;var i=A.T,c={};A.T=c,$i(l,!1,t,a);try{var s=u(),y=A.S;if(y!==null&&y(c,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var T=Oh(s,e);we(l,t,T,et(l))}else we(l,t,e,et(l))}catch(p){we(l,t,{then:function(){},status:"rejected",reason:p},et())}finally{x.p=n,A.T=i}}function xh(){}function Wi(l,t,a,e){if(l.tag!==5)throw Error(f(476));var u=Ps(l).queue;ks(l,u,t,X,a===null?xh:function(){return Is(l),a(e)})}function Ps(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:X,baseState:X,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ht,lastRenderedState:X},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ht,lastRenderedState:a},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function Is(l){var t=Ps(l).next.queue;we(l,t,{},et())}function Fi(){return Xl(ru)}function lo(){return _l().memoizedState}function to(){return _l().memoizedState}function Nh(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var a=et();l=Wt(a);var e=Ft(t,l,a);e!==null&&(ut(e,t,a),Le(e,t,a)),t={cache:zi()},l.payload=t;return}t=t.return}}function Hh(l,t,a){var e=et();a={lane:e,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null},Iu(l)?eo(t,a):(a=yi(l,t,a,e),a!==null&&(ut(a,l,e),uo(a,t,e)))}function ao(l,t,a){var e=et();we(l,t,a,e)}function we(l,t,a,e){var u={lane:e,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null};if(Iu(l))eo(t,u);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var i=t.lastRenderedState,c=n(i,a);if(u.hasEagerState=!0,u.eagerState=c,Pl(c,i))return Cu(l,t,u,0),rl===null&&Bu(),!1}catch{}finally{}if(a=yi(l,t,u,e),a!==null)return ut(a,l,e),uo(a,t,e),!0}return!1}function $i(l,t,a,e){if(e={lane:2,revertLane:Rc(),action:e,hasEagerState:!1,eagerState:null,next:null},Iu(l)){if(t)throw Error(f(479))}else t=yi(l,a,e,2),t!==null&&ut(t,l,2)}function Iu(l){var t=l.alternate;return l===J||t!==null&&t===J}function eo(l,t){le=wu=!0;var a=l.pending;a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t}function uo(l,t,a){if((a&4194048)!==0){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,df(l,a)}}var ln={readContext:Xl,use:Fu,useCallback:Tl,useContext:Tl,useEffect:Tl,useImperativeHandle:Tl,useLayoutEffect:Tl,useInsertionEffect:Tl,useMemo:Tl,useReducer:Tl,useRef:Tl,useState:Tl,useDebugValue:Tl,useDeferredValue:Tl,useTransition:Tl,useSyncExternalStore:Tl,useId:Tl,useHostTransitionStatus:Tl,useFormState:Tl,useActionState:Tl,useOptimistic:Tl,useMemoCache:Tl,useCacheRefresh:Tl},no={readContext:Xl,use:Fu,useCallback:function(l,t){return Jl().memoizedState=[l,t===void 0?null:t],l},useContext:Xl,useEffect:js,useImperativeHandle:function(l,t,a){a=a!=null?a.concat([l]):null,Pu(4194308,4,Js.bind(null,t,l),a)},useLayoutEffect:function(l,t){return Pu(4194308,4,l,t)},useInsertionEffect:function(l,t){Pu(4,2,l,t)},useMemo:function(l,t){var a=Jl();t=t===void 0?null:t;var e=l();if(Ra){Zt(!0);try{l()}finally{Zt(!1)}}return a.memoizedState=[e,t],e},useReducer:function(l,t,a){var e=Jl();if(a!==void 0){var u=a(t);if(Ra){Zt(!0);try{a(t)}finally{Zt(!1)}}}else u=t;return e.memoizedState=e.baseState=u,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},e.queue=l,l=l.dispatch=Hh.bind(null,J,l),[e.memoizedState,l]},useRef:function(l){var t=Jl();return l={current:l},t.memoizedState=l},useState:function(l){l=Vi(l);var t=l.queue,a=ao.bind(null,J,t);return t.dispatch=a,[l.memoizedState,a]},useDebugValue:Ji,useDeferredValue:function(l,t){var a=Jl();return wi(a,l,t)},useTransition:function(){var l=Vi(!1);return l=ks.bind(null,J,l.queue,!0,!1),Jl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,a){var e=J,u=Jl();if(tl){if(a===void 0)throw Error(f(407));a=a()}else{if(a=t(),rl===null)throw Error(f(349));(P&124)!==0||Os(e,t,a)}u.memoizedState=a;var n={value:a,getSnapshot:t};return u.queue=n,js(Rs.bind(null,e,n,l),[l]),e.flags|=2048,ae(9,ku(),Ds.bind(null,e,n,a,t),null),a},useId:function(){var l=Jl(),t=rl.identifierPrefix;if(tl){var a=Ut,e=Mt;a=(e&~(1<<32-kl(e)-1)).toString(32)+a,t="«"+t+"R"+a,a=Wu++,0G?(Ul=B,B=null):Ul=B.sibling;var ll=m(h,B,v[G],_);if(ll===null){B===null&&(B=Ul);break}l&&B&&ll.alternate===null&&t(h,B),r=n(ll,r,G),w===null?H=ll:w.sibling=ll,w=ll,B=Ul}if(G===v.length)return a(h,B),tl&&Aa(h,G),H;if(B===null){for(;GG?(Ul=B,B=null):Ul=B.sibling;var da=m(h,B,ll.value,_);if(da===null){B===null&&(B=Ul);break}l&&B&&da.alternate===null&&t(h,B),r=n(da,r,G),w===null?H=da:w.sibling=da,w=da,B=Ul}if(ll.done)return a(h,B),tl&&Aa(h,G),H;if(B===null){for(;!ll.done;G++,ll=v.next())ll=p(h,ll.value,_),ll!==null&&(r=n(ll,r,G),w===null?H=ll:w.sibling=ll,w=ll);return tl&&Aa(h,G),H}for(B=e(B);!ll.done;G++,ll=v.next())ll=g(B,h,G,ll.value,_),ll!==null&&(l&&ll.alternate!==null&&B.delete(ll.key===null?G:ll.key),r=n(ll,r,G),w===null?H=ll:w.sibling=ll,w=ll);return l&&B.forEach(function(Bv){return t(h,Bv)}),tl&&Aa(h,G),H}function cl(h,r,v,_){if(typeof v=="object"&&v!==null&&v.type===ml&&v.key===null&&(v=v.props.children),typeof v=="object"&&v!==null){switch(v.$$typeof){case W:l:{for(var H=v.key;r!==null;){if(r.key===H){if(H=v.type,H===ml){if(r.tag===7){a(h,r.sibling),_=u(r,v.props.children),_.return=h,h=_;break l}}else if(r.elementType===H||typeof H=="object"&&H!==null&&H.$$typeof===jl&&co(H)===r.type){a(h,r.sibling),_=u(r,v.props),Fe(_,v),_.return=h,h=_;break l}a(h,r);break}else t(h,r);r=r.sibling}v.type===ml?(_=Ta(v.props.children,h.mode,_,v.key),_.return=h,h=_):(_=Gu(v.type,v.key,v.props,null,h.mode,_),Fe(_,v),_.return=h,h=_)}return i(h);case K:l:{for(H=v.key;r!==null;){if(r.key===H)if(r.tag===4&&r.stateNode.containerInfo===v.containerInfo&&r.stateNode.implementation===v.implementation){a(h,r.sibling),_=u(r,v.children||[]),_.return=h,h=_;break l}else{a(h,r);break}else t(h,r);r=r.sibling}_=Si(v,h.mode,_),_.return=h,h=_}return i(h);case jl:return H=v._init,v=H(v._payload),cl(h,r,v,_)}if(Yl(v))return Q(h,r,v,_);if(Cl(v)){if(H=Cl(v),typeof H!="function")throw Error(f(150));return v=H.call(v),Y(h,r,v,_)}if(typeof v.then=="function")return cl(h,r,tn(v),_);if(v.$$typeof===dl)return cl(h,r,ju(h,v),_);an(h,v)}return typeof v=="string"&&v!==""||typeof v=="number"||typeof v=="bigint"?(v=""+v,r!==null&&r.tag===6?(a(h,r.sibling),_=u(r,v),_.return=h,h=_):(a(h,r),_=gi(v,h.mode,_),_.return=h,h=_),i(h)):a(h,r)}return function(h,r,v,_){try{We=0;var H=cl(h,r,v,_);return ee=null,H}catch(B){if(B===Xe||B===Vu)throw B;var w=Il(29,B,null,h.mode);return w.lanes=_,w.return=h,w}finally{}}}var ue=fo(!0),so=fo(!1),dt=O(null),Et=null;function kt(l){var t=l.alternate;U(zl,zl.current&1),U(dt,l),Et===null&&(t===null||Ia.current!==null||t.memoizedState!==null)&&(Et=l)}function oo(l){if(l.tag===22){if(U(zl,zl.current),U(dt,l),Et===null){var t=l.alternate;t!==null&&t.memoizedState!==null&&(Et=l)}}else Pt()}function Pt(){U(zl,zl.current),U(dt,dt.current)}function qt(l){N(dt),Et===l&&(Et=null),N(zl)}var zl=O(0);function en(l){for(var t=l;t!==null;){if(t.tag===13){var a=t.memoizedState;if(a!==null&&(a=a.dehydrated,a===null||a.data==="$?"||Qc(a)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===l)break;for(;t.sibling===null;){if(t.return===null||t.return===l)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function ki(l,t,a,e){t=l.memoizedState,a=a(e,t),a=a==null?t:M({},t,a),l.memoizedState=a,l.lanes===0&&(l.updateQueue.baseState=a)}var Pi={enqueueSetState:function(l,t,a){l=l._reactInternals;var e=et(),u=Wt(e);u.payload=t,a!=null&&(u.callback=a),t=Ft(l,u,e),t!==null&&(ut(t,l,e),Le(t,l,e))},enqueueReplaceState:function(l,t,a){l=l._reactInternals;var e=et(),u=Wt(e);u.tag=1,u.payload=t,a!=null&&(u.callback=a),t=Ft(l,u,e),t!==null&&(ut(t,l,e),Le(t,l,e))},enqueueForceUpdate:function(l,t){l=l._reactInternals;var a=et(),e=Wt(a);e.tag=2,t!=null&&(e.callback=t),t=Ft(l,e,a),t!==null&&(ut(t,l,a),Le(t,l,a))}};function ro(l,t,a,e,u,n,i){return l=l.stateNode,typeof l.shouldComponentUpdate=="function"?l.shouldComponentUpdate(e,n,i):t.prototype&&t.prototype.isPureReactComponent?!xe(a,e)||!xe(u,n):!0}function ho(l,t,a,e){l=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(a,e),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(a,e),t.state!==l&&Pi.enqueueReplaceState(t,t.state,null)}function Ma(l,t){var a=t;if("ref"in t){a={};for(var e in t)e!=="ref"&&(a[e]=t[e])}if(l=l.defaultProps){a===t&&(a=M({},a));for(var u in l)a[u]===void 0&&(a[u]=l[u])}return a}var un=typeof reportError=="function"?reportError:function(l){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof l=="object"&&l!==null&&typeof l.message=="string"?String(l.message):String(l),error:l});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",l);return}console.error(l)};function vo(l){un(l)}function yo(l){console.error(l)}function mo(l){un(l)}function nn(l,t){try{var a=l.onUncaughtError;a(t.value,{componentStack:t.stack})}catch(e){setTimeout(function(){throw e})}}function go(l,t,a){try{var e=l.onCaughtError;e(a.value,{componentStack:a.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(u){setTimeout(function(){throw u})}}function Ii(l,t,a){return a=Wt(a),a.tag=3,a.payload={element:null},a.callback=function(){nn(l,t)},a}function So(l){return l=Wt(l),l.tag=3,l}function bo(l,t,a,e){var u=a.type.getDerivedStateFromError;if(typeof u=="function"){var n=e.value;l.payload=function(){return u(n)},l.callback=function(){go(t,a,e)}}var i=a.stateNode;i!==null&&typeof i.componentDidCatch=="function"&&(l.callback=function(){go(t,a,e),typeof u!="function"&&(ua===null?ua=new Set([this]):ua.add(this));var c=e.stack;this.componentDidCatch(e.value,{componentStack:c!==null?c:""})})}function Bh(l,t,a,e,u){if(a.flags|=32768,e!==null&&typeof e=="object"&&typeof e.then=="function"){if(t=a.alternate,t!==null&&Ce(t,a,u,!0),a=dt.current,a!==null){switch(a.tag){case 13:return Et===null?_c():a.alternate===null&&bl===0&&(bl=3),a.flags&=-257,a.flags|=65536,a.lanes=u,e===Ri?a.flags|=16384:(t=a.updateQueue,t===null?a.updateQueue=new Set([e]):t.add(e),zc(l,e,u)),!1;case 22:return a.flags|=65536,e===Ri?a.flags|=16384:(t=a.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([e])},a.updateQueue=t):(a=t.retryQueue,a===null?t.retryQueue=new Set([e]):a.add(e)),zc(l,e,u)),!1}throw Error(f(435,a.tag))}return zc(l,e,u),_c(),!1}if(tl)return t=dt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=u,e!==Ei&&(l=Error(f(422),{cause:e}),Be(ft(l,a)))):(e!==Ei&&(t=Error(f(423),{cause:e}),Be(ft(t,a))),l=l.current.alternate,l.flags|=65536,u&=-u,l.lanes|=u,e=ft(e,a),u=Ii(l.stateNode,e,u),xi(l,u),bl!==4&&(bl=2)),!1;var n=Error(f(520),{cause:e});if(n=ft(n,a),au===null?au=[n]:au.push(n),bl!==4&&(bl=2),t===null)return!0;e=ft(e,a),a=t;do{switch(a.tag){case 3:return a.flags|=65536,l=u&-u,a.lanes|=l,l=Ii(a.stateNode,e,l),xi(a,l),!1;case 1:if(t=a.type,n=a.stateNode,(a.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||n!==null&&typeof n.componentDidCatch=="function"&&(ua===null||!ua.has(n))))return a.flags|=65536,u&=-u,a.lanes|=u,u=So(u),bo(u,l,a,e),xi(a,u),!1}a=a.return}while(a!==null);return!1}var To=Error(f(461)),Rl=!1;function Nl(l,t,a,e){t.child=l===null?so(t,null,a,e):ue(t,l.child,a,e)}function Eo(l,t,a,e,u){a=a.render;var n=t.ref;if("ref"in e){var i={};for(var c in e)c!=="ref"&&(i[c]=e[c])}else i=e;return Oa(t),e=Ci(l,t,a,i,n,u),c=Yi(),l!==null&&!Rl?(Gi(l,t,u),Bt(l,t,u)):(tl&&c&&bi(t),t.flags|=1,Nl(l,t,e,u),t.child)}function Ao(l,t,a,e,u){if(l===null){var n=a.type;return typeof n=="function"&&!mi(n)&&n.defaultProps===void 0&&a.compare===null?(t.tag=15,t.type=n,_o(l,t,n,e,u)):(l=Gu(a.type,null,e,t,t.mode,u),l.ref=t.ref,l.return=t,t.child=l)}if(n=l.child,!cc(l,u)){var i=n.memoizedProps;if(a=a.compare,a=a!==null?a:xe,a(i,e)&&l.ref===t.ref)return Bt(l,t,u)}return t.flags|=1,l=Rt(n,e),l.ref=t.ref,l.return=t,t.child=l}function _o(l,t,a,e,u){if(l!==null){var n=l.memoizedProps;if(xe(n,e)&&l.ref===t.ref)if(Rl=!1,t.pendingProps=e=n,cc(l,u))(l.flags&131072)!==0&&(Rl=!0);else return t.lanes=l.lanes,Bt(l,t,u)}return lc(l,t,a,e,u)}function po(l,t,a){var e=t.pendingProps,u=e.children,n=l!==null?l.memoizedState:null;if(e.mode==="hidden"){if((t.flags&128)!==0){if(e=n!==null?n.baseLanes|a:a,l!==null){for(u=t.child=l.child,n=0;u!==null;)n=n|u.lanes|u.childLanes,u=u.sibling;t.childLanes=n&~e}else t.childLanes=0,t.child=null;return zo(l,t,e,a)}if((a&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},l!==null&&Zu(t,n!==null?n.cachePool:null),n!==null?As(t,n):Hi(),oo(t);else return t.lanes=t.childLanes=536870912,zo(l,t,n!==null?n.baseLanes|a:a,a)}else n!==null?(Zu(t,n.cachePool),As(t,n),Pt(),t.memoizedState=null):(l!==null&&Zu(t,null),Hi(),Pt());return Nl(l,t,u,a),t.child}function zo(l,t,a,e){var u=Di();return u=u===null?null:{parent:pl._currentValue,pool:u},t.memoizedState={baseLanes:a,cachePool:u},l!==null&&Zu(t,null),Hi(),oo(t),l!==null&&Ce(l,t,e,!0),null}function cn(l,t){var a=t.ref;if(a===null)l!==null&&l.ref!==null&&(t.flags|=4194816);else{if(typeof a!="function"&&typeof a!="object")throw Error(f(284));(l===null||l.ref!==a)&&(t.flags|=4194816)}}function lc(l,t,a,e,u){return Oa(t),a=Ci(l,t,a,e,void 0,u),e=Yi(),l!==null&&!Rl?(Gi(l,t,u),Bt(l,t,u)):(tl&&e&&bi(t),t.flags|=1,Nl(l,t,a,u),t.child)}function Oo(l,t,a,e,u,n){return Oa(t),t.updateQueue=null,a=ps(t,e,a,u),_s(l),e=Yi(),l!==null&&!Rl?(Gi(l,t,n),Bt(l,t,n)):(tl&&e&&bi(t),t.flags|=1,Nl(l,t,a,n),t.child)}function Do(l,t,a,e,u){if(Oa(t),t.stateNode===null){var n=Wa,i=a.contextType;typeof i=="object"&&i!==null&&(n=Xl(i)),n=new a(e,n),t.memoizedState=n.state!==null&&n.state!==void 0?n.state:null,n.updater=Pi,t.stateNode=n,n._reactInternals=t,n=t.stateNode,n.props=e,n.state=t.memoizedState,n.refs={},Mi(t),i=a.contextType,n.context=typeof i=="object"&&i!==null?Xl(i):Wa,n.state=t.memoizedState,i=a.getDerivedStateFromProps,typeof i=="function"&&(ki(t,a,i,e),n.state=t.memoizedState),typeof a.getDerivedStateFromProps=="function"||typeof n.getSnapshotBeforeUpdate=="function"||typeof n.UNSAFE_componentWillMount!="function"&&typeof n.componentWillMount!="function"||(i=n.state,typeof n.componentWillMount=="function"&&n.componentWillMount(),typeof n.UNSAFE_componentWillMount=="function"&&n.UNSAFE_componentWillMount(),i!==n.state&&Pi.enqueueReplaceState(n,n.state,null),Ze(t,e,n,u),je(),n.state=t.memoizedState),typeof n.componentDidMount=="function"&&(t.flags|=4194308),e=!0}else if(l===null){n=t.stateNode;var c=t.memoizedProps,s=Ma(a,c);n.props=s;var y=n.context,T=a.contextType;i=Wa,typeof T=="object"&&T!==null&&(i=Xl(T));var p=a.getDerivedStateFromProps;T=typeof p=="function"||typeof n.getSnapshotBeforeUpdate=="function",c=t.pendingProps!==c,T||typeof n.UNSAFE_componentWillReceiveProps!="function"&&typeof n.componentWillReceiveProps!="function"||(c||y!==i)&&ho(t,n,e,i),wt=!1;var m=t.memoizedState;n.state=m,Ze(t,e,n,u),je(),y=t.memoizedState,c||m!==y||wt?(typeof p=="function"&&(ki(t,a,p,e),y=t.memoizedState),(s=wt||ro(t,a,s,e,m,y,i))?(T||typeof n.UNSAFE_componentWillMount!="function"&&typeof n.componentWillMount!="function"||(typeof n.componentWillMount=="function"&&n.componentWillMount(),typeof n.UNSAFE_componentWillMount=="function"&&n.UNSAFE_componentWillMount()),typeof n.componentDidMount=="function"&&(t.flags|=4194308)):(typeof n.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=e,t.memoizedState=y),n.props=e,n.state=y,n.context=i,e=s):(typeof n.componentDidMount=="function"&&(t.flags|=4194308),e=!1)}else{n=t.stateNode,Ui(l,t),i=t.memoizedProps,T=Ma(a,i),n.props=T,p=t.pendingProps,m=n.context,y=a.contextType,s=Wa,typeof y=="object"&&y!==null&&(s=Xl(y)),c=a.getDerivedStateFromProps,(y=typeof c=="function"||typeof n.getSnapshotBeforeUpdate=="function")||typeof n.UNSAFE_componentWillReceiveProps!="function"&&typeof n.componentWillReceiveProps!="function"||(i!==p||m!==s)&&ho(t,n,e,s),wt=!1,m=t.memoizedState,n.state=m,Ze(t,e,n,u),je();var g=t.memoizedState;i!==p||m!==g||wt||l!==null&&l.dependencies!==null&&Lu(l.dependencies)?(typeof c=="function"&&(ki(t,a,c,e),g=t.memoizedState),(T=wt||ro(t,a,T,e,m,g,s)||l!==null&&l.dependencies!==null&&Lu(l.dependencies))?(y||typeof n.UNSAFE_componentWillUpdate!="function"&&typeof n.componentWillUpdate!="function"||(typeof n.componentWillUpdate=="function"&&n.componentWillUpdate(e,g,s),typeof n.UNSAFE_componentWillUpdate=="function"&&n.UNSAFE_componentWillUpdate(e,g,s)),typeof n.componentDidUpdate=="function"&&(t.flags|=4),typeof n.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof n.componentDidUpdate!="function"||i===l.memoizedProps&&m===l.memoizedState||(t.flags|=4),typeof n.getSnapshotBeforeUpdate!="function"||i===l.memoizedProps&&m===l.memoizedState||(t.flags|=1024),t.memoizedProps=e,t.memoizedState=g),n.props=e,n.state=g,n.context=s,e=T):(typeof n.componentDidUpdate!="function"||i===l.memoizedProps&&m===l.memoizedState||(t.flags|=4),typeof n.getSnapshotBeforeUpdate!="function"||i===l.memoizedProps&&m===l.memoizedState||(t.flags|=1024),e=!1)}return n=e,cn(l,t),e=(t.flags&128)!==0,n||e?(n=t.stateNode,a=e&&typeof a.getDerivedStateFromError!="function"?null:n.render(),t.flags|=1,l!==null&&e?(t.child=ue(t,l.child,null,u),t.child=ue(t,null,a,u)):Nl(l,t,a,u),t.memoizedState=n.state,l=t.child):l=Bt(l,t,u),l}function Ro(l,t,a,e){return qe(),t.flags|=256,Nl(l,t,a,e),t.child}var tc={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function ac(l){return{baseLanes:l,cachePool:vs()}}function ec(l,t,a){return l=l!==null?l.childLanes&~a:0,t&&(l|=ht),l}function Mo(l,t,a){var e=t.pendingProps,u=!1,n=(t.flags&128)!==0,i;if((i=n)||(i=l!==null&&l.memoizedState===null?!1:(zl.current&2)!==0),i&&(u=!0,t.flags&=-129),i=(t.flags&32)!==0,t.flags&=-33,l===null){if(tl){if(u?kt(t):Pt(),tl){var c=Sl,s;if(s=c){l:{for(s=c,c=Tt;s.nodeType!==8;){if(!c){c=null;break l}if(s=gt(s.nextSibling),s===null){c=null;break l}}c=s}c!==null?(t.memoizedState={dehydrated:c,treeContext:Ea!==null?{id:Mt,overflow:Ut}:null,retryLane:536870912,hydrationErrors:null},s=Il(18,null,null,0),s.stateNode=c,s.return=t,t.child=s,Zl=t,Sl=null,s=!0):s=!1}s||pa(t)}if(c=t.memoizedState,c!==null&&(c=c.dehydrated,c!==null))return Qc(c)?t.lanes=32:t.lanes=536870912,null;qt(t)}return c=e.children,e=e.fallback,u?(Pt(),u=t.mode,c=fn({mode:"hidden",children:c},u),e=Ta(e,u,a,null),c.return=t,e.return=t,c.sibling=e,t.child=c,u=t.child,u.memoizedState=ac(a),u.childLanes=ec(l,i,a),t.memoizedState=tc,e):(kt(t),uc(t,c))}if(s=l.memoizedState,s!==null&&(c=s.dehydrated,c!==null)){if(n)t.flags&256?(kt(t),t.flags&=-257,t=nc(l,t,a)):t.memoizedState!==null?(Pt(),t.child=l.child,t.flags|=128,t=null):(Pt(),u=e.fallback,c=t.mode,e=fn({mode:"visible",children:e.children},c),u=Ta(u,c,a,null),u.flags|=2,e.return=t,u.return=t,e.sibling=u,t.child=e,ue(t,l.child,null,a),e=t.child,e.memoizedState=ac(a),e.childLanes=ec(l,i,a),t.memoizedState=tc,t=u);else if(kt(t),Qc(c)){if(i=c.nextSibling&&c.nextSibling.dataset,i)var y=i.dgst;i=y,e=Error(f(419)),e.stack="",e.digest=i,Be({value:e,source:null,stack:null}),t=nc(l,t,a)}else if(Rl||Ce(l,t,a,!1),i=(a&l.childLanes)!==0,Rl||i){if(i=rl,i!==null&&(e=a&-a,e=(e&42)!==0?1:Ln(e),e=(e&(i.suspendedLanes|a))!==0?0:e,e!==0&&e!==s.retryLane))throw s.retryLane=e,wa(l,e),ut(i,l,e),To;c.data==="$?"||_c(),t=nc(l,t,a)}else c.data==="$?"?(t.flags|=192,t.child=l.child,t=null):(l=s.treeContext,Sl=gt(c.nextSibling),Zl=t,tl=!0,_a=null,Tt=!1,l!==null&&(ot[rt++]=Mt,ot[rt++]=Ut,ot[rt++]=Ea,Mt=l.id,Ut=l.overflow,Ea=t),t=uc(t,e.children),t.flags|=4096);return t}return u?(Pt(),u=e.fallback,c=t.mode,s=l.child,y=s.sibling,e=Rt(s,{mode:"hidden",children:e.children}),e.subtreeFlags=s.subtreeFlags&65011712,y!==null?u=Rt(y,u):(u=Ta(u,c,a,null),u.flags|=2),u.return=t,e.return=t,e.sibling=u,t.child=e,e=u,u=t.child,c=l.child.memoizedState,c===null?c=ac(a):(s=c.cachePool,s!==null?(y=pl._currentValue,s=s.parent!==y?{parent:y,pool:y}:s):s=vs(),c={baseLanes:c.baseLanes|a,cachePool:s}),u.memoizedState=c,u.childLanes=ec(l,i,a),t.memoizedState=tc,e):(kt(t),a=l.child,l=a.sibling,a=Rt(a,{mode:"visible",children:e.children}),a.return=t,a.sibling=null,l!==null&&(i=t.deletions,i===null?(t.deletions=[l],t.flags|=16):i.push(l)),t.child=a,t.memoizedState=null,a)}function uc(l,t){return t=fn({mode:"visible",children:t},l.mode),t.return=l,l.child=t}function fn(l,t){return l=Il(22,l,null,t),l.lanes=0,l.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},l}function nc(l,t,a){return ue(t,l.child,null,a),l=uc(t,t.pendingProps.children),l.flags|=2,t.memoizedState=null,l}function Uo(l,t,a){l.lanes|=t;var e=l.alternate;e!==null&&(e.lanes|=t),_i(l.return,t,a)}function ic(l,t,a,e,u){var n=l.memoizedState;n===null?l.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:e,tail:a,tailMode:u}:(n.isBackwards=t,n.rendering=null,n.renderingStartTime=0,n.last=e,n.tail=a,n.tailMode=u)}function xo(l,t,a){var e=t.pendingProps,u=e.revealOrder,n=e.tail;if(Nl(l,t,e.children,a),e=zl.current,(e&2)!==0)e=e&1|2,t.flags|=128;else{if(l!==null&&(l.flags&128)!==0)l:for(l=t.child;l!==null;){if(l.tag===13)l.memoizedState!==null&&Uo(l,a,t);else if(l.tag===19)Uo(l,a,t);else if(l.child!==null){l.child.return=l,l=l.child;continue}if(l===t)break l;for(;l.sibling===null;){if(l.return===null||l.return===t)break l;l=l.return}l.sibling.return=l.return,l=l.sibling}e&=1}switch(U(zl,e),u){case"forwards":for(a=t.child,u=null;a!==null;)l=a.alternate,l!==null&&en(l)===null&&(u=a),a=a.sibling;a=u,a===null?(u=t.child,t.child=null):(u=a.sibling,a.sibling=null),ic(t,!1,u,a,n);break;case"backwards":for(a=null,u=t.child,t.child=null;u!==null;){if(l=u.alternate,l!==null&&en(l)===null){t.child=u;break}l=u.sibling,u.sibling=a,a=u,u=l}ic(t,!0,a,null,n);break;case"together":ic(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Bt(l,t,a){if(l!==null&&(t.dependencies=l.dependencies),ea|=t.lanes,(a&t.childLanes)===0)if(l!==null){if(Ce(l,t,a,!1),(a&t.childLanes)===0)return null}else return null;if(l!==null&&t.child!==l.child)throw Error(f(153));if(t.child!==null){for(l=t.child,a=Rt(l,l.pendingProps),t.child=a,a.return=t;l.sibling!==null;)l=l.sibling,a=a.sibling=Rt(l,l.pendingProps),a.return=t;a.sibling=null}return t.child}function cc(l,t){return(l.lanes&t)!==0?!0:(l=l.dependencies,!!(l!==null&&Lu(l)))}function Ch(l,t,a){switch(t.tag){case 3:hl(t,t.stateNode.containerInfo),Jt(t,pl,l.memoizedState.cache),qe();break;case 27:case 5:Cn(t);break;case 4:hl(t,t.stateNode.containerInfo);break;case 10:Jt(t,t.type,t.memoizedProps.value);break;case 13:var e=t.memoizedState;if(e!==null)return e.dehydrated!==null?(kt(t),t.flags|=128,null):(a&t.child.childLanes)!==0?Mo(l,t,a):(kt(t),l=Bt(l,t,a),l!==null?l.sibling:null);kt(t);break;case 19:var u=(l.flags&128)!==0;if(e=(a&t.childLanes)!==0,e||(Ce(l,t,a,!1),e=(a&t.childLanes)!==0),u){if(e)return xo(l,t,a);t.flags|=128}if(u=t.memoizedState,u!==null&&(u.rendering=null,u.tail=null,u.lastEffect=null),U(zl,zl.current),e)break;return null;case 22:case 23:return t.lanes=0,po(l,t,a);case 24:Jt(t,pl,l.memoizedState.cache)}return Bt(l,t,a)}function No(l,t,a){if(l!==null)if(l.memoizedProps!==t.pendingProps)Rl=!0;else{if(!cc(l,a)&&(t.flags&128)===0)return Rl=!1,Ch(l,t,a);Rl=(l.flags&131072)!==0}else Rl=!1,tl&&(t.flags&1048576)!==0&&cs(t,Qu,t.index);switch(t.lanes=0,t.tag){case 16:l:{l=t.pendingProps;var e=t.elementType,u=e._init;if(e=u(e._payload),t.type=e,typeof e=="function")mi(e)?(l=Ma(e,l),t.tag=1,t=Do(null,t,e,l,a)):(t.tag=0,t=lc(null,t,e,l,a));else{if(e!=null){if(u=e.$$typeof,u===Bl){t.tag=11,t=Eo(null,t,e,l,a);break l}else if(u===Ll){t.tag=14,t=Ao(null,t,e,l,a);break l}}throw t=va(e)||e,Error(f(306,t,""))}}return t;case 0:return lc(l,t,t.type,t.pendingProps,a);case 1:return e=t.type,u=Ma(e,t.pendingProps),Do(l,t,e,u,a);case 3:l:{if(hl(t,t.stateNode.containerInfo),l===null)throw Error(f(387));e=t.pendingProps;var n=t.memoizedState;u=n.element,Ui(l,t),Ze(t,e,null,a);var i=t.memoizedState;if(e=i.cache,Jt(t,pl,e),e!==n.cache&&pi(t,[pl],a,!0),je(),e=i.element,n.isDehydrated)if(n={element:e,isDehydrated:!1,cache:i.cache},t.updateQueue.baseState=n,t.memoizedState=n,t.flags&256){t=Ro(l,t,e,a);break l}else if(e!==u){u=ft(Error(f(424)),t),Be(u),t=Ro(l,t,e,a);break l}else{switch(l=t.stateNode.containerInfo,l.nodeType){case 9:l=l.body;break;default:l=l.nodeName==="HTML"?l.ownerDocument.body:l}for(Sl=gt(l.firstChild),Zl=t,tl=!0,_a=null,Tt=!0,a=so(t,null,e,a),t.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling}else{if(qe(),e===u){t=Bt(l,t,a);break l}Nl(l,t,e,a)}t=t.child}return t;case 26:return cn(l,t),l===null?(a=Cr(t.type,null,t.pendingProps,null))?t.memoizedState=a:tl||(a=t.type,l=t.pendingProps,e=An(Z.current).createElement(a),e[Gl]=t,e[Vl]=l,ql(e,a,l),Dl(e),t.stateNode=e):t.memoizedState=Cr(t.type,l.memoizedProps,t.pendingProps,l.memoizedState),null;case 27:return Cn(t),l===null&&tl&&(e=t.stateNode=Hr(t.type,t.pendingProps,Z.current),Zl=t,Tt=!0,u=Sl,ca(t.type)?(Lc=u,Sl=gt(e.firstChild)):Sl=u),Nl(l,t,t.pendingProps.children,a),cn(l,t),l===null&&(t.flags|=4194304),t.child;case 5:return l===null&&tl&&((u=e=Sl)&&(e=ov(e,t.type,t.pendingProps,Tt),e!==null?(t.stateNode=e,Zl=t,Sl=gt(e.firstChild),Tt=!1,u=!0):u=!1),u||pa(t)),Cn(t),u=t.type,n=t.pendingProps,i=l!==null?l.memoizedProps:null,e=n.children,Yc(u,n)?e=null:i!==null&&Yc(u,i)&&(t.flags|=32),t.memoizedState!==null&&(u=Ci(l,t,Rh,null,null,a),ru._currentValue=u),cn(l,t),Nl(l,t,e,a),t.child;case 6:return l===null&&tl&&((l=a=Sl)&&(a=rv(a,t.pendingProps,Tt),a!==null?(t.stateNode=a,Zl=t,Sl=null,l=!0):l=!1),l||pa(t)),null;case 13:return Mo(l,t,a);case 4:return hl(t,t.stateNode.containerInfo),e=t.pendingProps,l===null?t.child=ue(t,null,e,a):Nl(l,t,e,a),t.child;case 11:return Eo(l,t,t.type,t.pendingProps,a);case 7:return Nl(l,t,t.pendingProps,a),t.child;case 8:return Nl(l,t,t.pendingProps.children,a),t.child;case 12:return Nl(l,t,t.pendingProps.children,a),t.child;case 10:return e=t.pendingProps,Jt(t,t.type,e.value),Nl(l,t,e.children,a),t.child;case 9:return u=t.type._context,e=t.pendingProps.children,Oa(t),u=Xl(u),e=e(u),t.flags|=1,Nl(l,t,e,a),t.child;case 14:return Ao(l,t,t.type,t.pendingProps,a);case 15:return _o(l,t,t.type,t.pendingProps,a);case 19:return xo(l,t,a);case 31:return e=t.pendingProps,a=t.mode,e={mode:e.mode,children:e.children},l===null?(a=fn(e,a),a.ref=t.ref,t.child=a,a.return=t,t=a):(a=Rt(l.child,e),a.ref=t.ref,t.child=a,a.return=t,t=a),t;case 22:return po(l,t,a);case 24:return Oa(t),e=Xl(pl),l===null?(u=Di(),u===null&&(u=rl,n=zi(),u.pooledCache=n,n.refCount++,n!==null&&(u.pooledCacheLanes|=a),u=n),t.memoizedState={parent:e,cache:u},Mi(t),Jt(t,pl,u)):((l.lanes&a)!==0&&(Ui(l,t),Ze(t,null,null,a),je()),u=l.memoizedState,n=t.memoizedState,u.parent!==e?(u={parent:e,cache:e},t.memoizedState=u,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=u),Jt(t,pl,e)):(e=n.cache,Jt(t,pl,e),e!==u.cache&&pi(t,[pl],a,!0))),Nl(l,t,t.pendingProps.children,a),t.child;case 29:throw t.pendingProps}throw Error(f(156,t.tag))}function Ct(l){l.flags|=4}function Ho(l,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)l.flags&=-16777217;else if(l.flags|=16777216,!Lr(t)){if(t=dt.current,t!==null&&((P&4194048)===P?Et!==null:(P&62914560)!==P&&(P&536870912)===0||t!==Et))throw Qe=Ri,ys;l.flags|=8192}}function sn(l,t){t!==null&&(l.flags|=4),l.flags&16384&&(t=l.tag!==22?of():536870912,l.lanes|=t,fe|=t)}function $e(l,t){if(!tl)switch(l.tailMode){case"hidden":t=l.tail;for(var a=null;t!==null;)t.alternate!==null&&(a=t),t=t.sibling;a===null?l.tail=null:a.sibling=null;break;case"collapsed":a=l.tail;for(var e=null;a!==null;)a.alternate!==null&&(e=a),a=a.sibling;e===null?t||l.tail===null?l.tail=null:l.tail.sibling=null:e.sibling=null}}function yl(l){var t=l.alternate!==null&&l.alternate.child===l.child,a=0,e=0;if(t)for(var u=l.child;u!==null;)a|=u.lanes|u.childLanes,e|=u.subtreeFlags&65011712,e|=u.flags&65011712,u.return=l,u=u.sibling;else for(u=l.child;u!==null;)a|=u.lanes|u.childLanes,e|=u.subtreeFlags,e|=u.flags,u.return=l,u=u.sibling;return l.subtreeFlags|=e,l.childLanes=a,t}function Yh(l,t,a){var e=t.pendingProps;switch(Ti(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return yl(t),null;case 1:return yl(t),null;case 3:return a=t.stateNode,e=null,l!==null&&(e=l.memoizedState.cache),t.memoizedState.cache!==e&&(t.flags|=2048),Nt(pl),jt(),a.pendingContext&&(a.context=a.pendingContext,a.pendingContext=null),(l===null||l.child===null)&&(He(t)?Ct(t):l===null||l.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,os())),yl(t),null;case 26:return a=t.memoizedState,l===null?(Ct(t),a!==null?(yl(t),Ho(t,a)):(yl(t),t.flags&=-16777217)):a?a!==l.memoizedState?(Ct(t),yl(t),Ho(t,a)):(yl(t),t.flags&=-16777217):(l.memoizedProps!==e&&Ct(t),yl(t),t.flags&=-16777217),null;case 27:bu(t),a=Z.current;var u=t.type;if(l!==null&&t.stateNode!=null)l.memoizedProps!==e&&Ct(t);else{if(!e){if(t.stateNode===null)throw Error(f(166));return yl(t),null}l=C.current,He(t)?fs(t):(l=Hr(u,e,a),t.stateNode=l,Ct(t))}return yl(t),null;case 5:if(bu(t),a=t.type,l!==null&&t.stateNode!=null)l.memoizedProps!==e&&Ct(t);else{if(!e){if(t.stateNode===null)throw Error(f(166));return yl(t),null}if(l=C.current,He(t))fs(t);else{switch(u=An(Z.current),l){case 1:l=u.createElementNS("http://www.w3.org/2000/svg",a);break;case 2:l=u.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;default:switch(a){case"svg":l=u.createElementNS("http://www.w3.org/2000/svg",a);break;case"math":l=u.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;case"script":l=u.createElement("div"),l.innerHTML=" + + + +
+ + diff --git a/web/ably_chat/public/vff/vite.svg b/web/ably_chat/public/vff/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/ably_chat/public/vff/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/ably_chat/scripts/remove-mock.js b/web/ably_chat/scripts/remove-mock.js index f7c04fe..13bf47f 100644 --- a/web/ably_chat/scripts/remove-mock.js +++ b/web/ably_chat/scripts/remove-mock.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); -// 获取构建输出目录 +// Get build output directory const buildDir = path.join(__dirname, '../../../data/html/chat'); const mockDir = path.join(buildDir, 'mock'); diff --git a/web/ably_chat/src/api/auth.js b/web/ably_chat/src/api/auth.js deleted file mode 100644 index f11b7ad..0000000 --- a/web/ably_chat/src/api/auth.js +++ /dev/null @@ -1,62 +0,0 @@ -const apiUrl = process.env.NEXT_PUBLIC_API_URL; -const jwtToken = process.env.NEXT_PUBLIC_JWT_TOKEN; -async function getAblyTokenFromServerByRoomID(roomID) { - - const url = `${apiUrl}/api/v1/messenger/token?type=3&roomID=${encodeURIComponent(roomID)}`; - try { - const res = await fetch(url, { - method: "GET", - headers: { - "Authorization": 'Bearer ' + jwtToken, - } - }); - - if (!res.ok) { - throw new Error(`Invalid status code: ${res.status}`); - } - - const resBody = await res.json(); - - // Structure example: { provider: 3, token: "xxxx" } - return resBody.token; - } catch (err) { - console.error("Failed to get Ably token:", err); - throw err; - } -} - -export async function getAblyTokenFromServer(roomID = '') { - if (process.env.NODE_ENV === 'development') { - // In development, call getAblyTokenFromServerByRoomID - // You might need to pass roomID and jwtToken if they are not globally available - // or adjust how they are accessed within this function. - // Assuming roomID and jwtToken are accessible here as defined in the file scope - return await getAblyTokenFromServerByRoomID(roomID, jwtToken); - } else { - // In production, execute the original logic - const url = `/lapi`; - const data = { - action: 'getAblyToken', - } - try { - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(data) - }); - - if (!res.ok) { - throw new Error(`Invalid status code: ${res.status}`); - } - - const resBody = await res.json(); - // console.log(resBody); - return resBody.token; - } catch (err) { - console.error("Failed to get Ably token:", err); - throw err; - } - } -} diff --git a/web/ably_chat/src/api/gifts.js b/web/ably_chat/src/api/gifts.js deleted file mode 100644 index 4d62b4d..0000000 --- a/web/ably_chat/src/api/gifts.js +++ /dev/null @@ -1,62 +0,0 @@ -// Used to store gift information -let giftsMap = new Map(); - -export async function getGifts() { - if (process.env.NODE_ENV === 'development') { - try { - // In development environment, read gift information from local JSON file - const response = await fetch('/mock/get_gifts_response.json'); - if (!response.ok) { - throw new Error(`Failed to fetch gifts: ${response.status}`); - } - const giftsData = await response.json(); - if (giftsData && giftsData.gifts) { - giftsData.gifts.forEach(gift => { - giftsMap.set(gift.giftID, gift); - }); - // console.log('Gifts loaded from local JSON:', giftsMap.size); - } else { - console.error('Invalid gifts data structure in local JSON'); - } - } catch (error) { - console.error('Error loading gifts from local JSON:', error); - } - } else { - const url = `/lapi`; - const data = { - action: 'getGifts', - } - try { - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(data) - }); - - if (!res.ok) { - throw new Error(`Failed to fetch gifts from server: ${res.status}`); - } - - const giftsData = await res.json(); - if (giftsData && giftsData.gifts) { - giftsData.gifts.forEach(gift => { - giftsMap.set(gift.giftID, gift); - }); - console.log('Gifts loaded from server:', giftsMap.size); - } else { - console.error('Invalid gifts data structure from server'); - } - } catch (err) { - console.error('Error loading gifts from server:', err); - throw err; - } - } -} - -export function getGiftByID(giftID) { - // Get gift information by giftID - // Assuming giftsMap is a Map where key is giftID and value is gift information - return giftsMap.get(giftID); -} diff --git a/web/ably_chat/src/api/index.js b/web/ably_chat/src/api/index.js deleted file mode 100644 index 4f5e764..0000000 --- a/web/ably_chat/src/api/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export * from './auth' -export * from './room' -export * from './gifts' \ No newline at end of file diff --git a/web/ably_chat/src/app/[locale]/Ably.jsx b/web/ably_chat/src/app/[locale]/Ably.jsx deleted file mode 100644 index d3de1bc..0000000 --- a/web/ably_chat/src/app/[locale]/Ably.jsx +++ /dev/null @@ -1,405 +0,0 @@ -'use client' - -import React, { useState, useEffect, useRef, use } from 'react'; -import { useTranslations } from 'next-intl'; - -import * as Ably from 'ably'; -import shortid from 'shortid'; -import { fromJS } from 'immutable'; - -import Chat from '@/lib/Chat'; -import { getAblyDecodeData } from '@/util/getAblyDecodeData'; -import { getChatProps } from '@/util/getChatProps'; -import { ChatListWrapper } from '@/lib/ChatListWrapper'; -import { - getAblyTokenFromServer, - getGifts, - getGiftByID, - getRoomInfo -} from '../../api'; - -import { - MsgType_COMMENT, - MsgType_NEW_GIFT, - MsgType_NEW_LUCKYBAG, - MsgType_JOIN_ROOM, - MsgType_AI_COHOST_MESSAGE, - DEFAULT_STREAMER_COMMENT_BG_COLOR_1, - MsgType_POKE, -} from '@/lib/constants'; - -// import giftdata from '@/../public/mock/chat_new_gift_2.json'; -// import comment from '@/../public/mock/chat_message.json'; -// import newjoin from '@/../public/mock/chat_new_join.json'; -// import aicohost from '@/../public/mock/chat_ai_cohost.json'; -// import pokeone from '@/../public/mock/chat_poke.json'; -// import pokeall from '@/../public/mock/chat_poke_all.json'; -// import pokeback0 from '@/../public/mock/chat_poke_back_0.json'; -// import pokeback1 from '@/../public/mock/chat_poke_back_1.json'; -// import pokeback2 from '@/../public/mock/chat_poke_back_2.json'; -// import pokeback3 from '@/../public/mock/chat_poke_back_3.json'; - -export default function AblyComponent() { - - const [chatList, setChatList] = useState([]); - - const [roomID, setRoomID] = useState(''); - const [userID, setUserID] = useState(''); - - const [roomInfo, setRoomInfo] = useState(null); - const chatEndRef = useRef(null); - - // Add window width state - const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200); - - const t = useTranslations('ChatPage'); - - // Listen for window resize events - useEffect(() => { - const handleResize = () => { - setWindowWidth(window.innerWidth); - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - // Calculate responsive asideLiveWidth - const getAsideLiveWidth = () => { - // Calculate based on window width, minimum 378, maximum 600 - return Math.max(378, windowWidth); - }; - - // Save chat to local storage - const saveChatToStorage = (roomId, chatData) => { - try { - const storageKey = `chat_history_${roomId}`; - // Convert Immutable object to plain JavaScript object for storage - const plainChats = chatData.map(chat => chat.toJS ? chat.toJS() : chat); - const chatHistory = { - roomId, - timestamp: Date.now(), - chats: plainChats - }; - localStorage.setItem(storageKey, JSON.stringify(chatHistory)); - } catch (error) { - console.error('Error saving chat to storage:', error); - } - }; - - // load chat from local storage - const loadChatFromStorage = (roomId) => { - try { - const storageKey = `chat_history_${roomId}`; - const savedData = localStorage.getItem(storageKey); - if (savedData) { - const chatHistory = JSON.parse(savedData); - // check if the data is expired - const isExpired = Date.now() - chatHistory.timestamp > 24 * 60 * 60 * 1000; - if (!isExpired && chatHistory.chats) { - return chatHistory.chats.map(chat => fromJS(chat)); - } - } - } catch (error) { - console.error('Error loading chat from storage:', error); - } - return []; - }; - - // clear chat history - const cleanupExpiredChats = () => { - try { - const keys = Object.keys(localStorage); - keys.forEach(key => { - if (key.startsWith('chat_history_')) { - const savedData = localStorage.getItem(key); - if (savedData) { - const chatHistory = JSON.parse(savedData); - const isExpired = Date.now() - chatHistory.timestamp > 24 * 60 * 60 * 1000; - if (isExpired) { - localStorage.removeItem(key); - } - } - } - }); - } catch (error) { - console.error('Error cleaning up expired chats:', error); - } - }; - - const prepareIndexedChat = (message) => { - const id = shortid.generate(); - const { userInfo } = roomInfo; - const streamerInfo = userInfo; - - if (message.type === MsgType_NEW_GIFT - || message.type === MsgType_NEW_LUCKYBAG) { - const { displayUser, barrage, ...restGift } = message?.giftMsg; - const gift = getGiftByID(restGift.giftID); - - if (message.type === MsgType_NEW_LUCKYBAG && restGift.extID) { - const luckyBag = getGiftByID(restGift.extID); - const indexedGift = fromJS({ - ...restGift, - ...displayUser, - barrage, - id, - messageType: message.type, - gift, - luckyBag, - streamerInfo, - }); - return indexedGift; - } - - - const indexedGift = fromJS({ - ...restGift, - ...displayUser, - barrage, - id, - messageType: message.type, - gift, - streamerInfo, - }); - return indexedGift; // Return the gift message - } else if (message.type === MsgType_AI_COHOST_MESSAGE) { - const { commentTxt } = message?.aiCohostMsg; - const indexedChat = fromJS({ - content: commentTxt, - comment: { - textColor: "#333333", - }, - displayName: t('AI_COHOST'), - name: { - textColor: "#527fff", - }, - backgroundColor: "#FFFFFFE6", - id, - messageType: message.type, - streamerInfo, - }); - return indexedChat; // Return the AI cohost message - } else if (message.type === MsgType_POKE) { - const { sender, ...restPoke } = message?.pokeInfo; - return fromJS({ - ...sender, - isStreamer: sender.userID === streamerInfo.userID, - pokeInfo: message?.pokeInfo, - id, - messageType: message.type, - streamerInfo, - // comment: { - // textColor: "#33CEB0", - // }, - }) - } - - const { displayUser, barrage, ...restChat } = message?.commentMsg; - - const indexedChat = fromJS({ - ...restChat, - ...displayUser, - barrage, - id, - messageType: message.type, - streamerInfo, - }); - return indexedChat; - } - - useEffect(() => { - const fetchInitialData = async () => { - const urlParams = new URLSearchParams(window.location.search); - const roomIDFromUrl = urlParams.get('roomID'); - const userIDFromUrl = urlParams.get('userID'); - if (roomIDFromUrl) { - setRoomID(roomIDFromUrl); - } - if (userIDFromUrl) { - setUserID(userIDFromUrl); - } - - try { - const roomInfo = await getRoomInfo(); - setRoomInfo(roomInfo); - - // load gifts - await getGifts(); - - // load chat history from local storage - cleanupExpiredChats(); - } catch (error) { - console.error("Error fetching initial data:", error); - } - }; - fetchInitialData(); - }, []); - - // load chat history from local storage when roomID changes - useEffect(() => { - if (roomID) { - const savedChats = loadChatFromStorage(roomID); - setChatList(savedChats); - } - }, [roomID]); - - // save chat to local storage - useEffect(() => { - if (roomID && chatList.length > 0) { - saveChatToStorage(roomID, chatList); - } - }, [chatList, roomID]); - - // 記錄用戶是否在底部的狀態 - const [isUserNearBottom, setIsUserNearBottom] = useState(true); - - // 監聽滾動事件,檢測用戶是否在底部附近 - useEffect(() => { - const chatContainer = document.querySelector('.chat-list-wrapper'); - if (!chatContainer) return; - - const handleScroll = () => { - // 計算用戶是否已經接近底部(距離底部小於100px) - const isNearBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight < 100; - setIsUserNearBottom(isNearBottom); - }; - - // 初始檢查 - handleScroll(); - - // 添加滾動事件監聽 - chatContainer.addEventListener('scroll', handleScroll); - - // 清理函數 - return () => { - chatContainer.removeEventListener('scroll', handleScroll); - }; - }, []); - - // 僅當用戶在底部附近且聊天記錄更新時,才自動滾動到底部 - useEffect(() => { - // 確保只有當聊天列表有內容且用戶在底部附近時才滾動 - if (chatEndRef.current && isUserNearBottom && chatList.length > 0) { - // 使用 smooth 行為確保滾動平滑,且只滾動到底部 - chatEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); - } - }, [chatList, isUserNearBottom]); - - useEffect(() => { - if (!roomID || !userID) { - return; - } - - // setTimeout(() => { - // console.log('loading mock messages...'); - // setChatList([ - // prepareIndexedChat(comment), - // prepareIndexedChat(newjoin), - // prepareIndexedChat(giftdata), - // prepareIndexedChat(aicohost), - // prepareIndexedChat(pokeone), - // prepareIndexedChat(pokeall), - // prepareIndexedChat(pokeback0), - // prepareIndexedChat(pokeback1), - // prepareIndexedChat(pokeback2), - // prepareIndexedChat(pokeback3), - // ]); - // }, 1000); - - const ably = new Ably.Realtime({ - environment: '17media', - fallbackHosts: [ - '17-media-a-fallback.ably-realtime.com', - '17-media-b-fallback.ably-realtime.com', - '17-media-c-fallback.ably-realtime.com', - ], - - authCallback: async (data, cb) => { - const token = await getAblyTokenFromServer(roomID); - cb(null, token); - }, - }) - const channel = ably.channels.get(roomID); - - channel.subscribe((message) => { - const decodeMessage = getAblyDecodeData(message); - const streamerInfo = roomInfo.userInfo; - - // console.log('new message:', decodeMessage.type, ' - content: ', decodeMessage); - - if (decodeMessage?.type === MsgType_COMMENT - || decodeMessage?.type === MsgType_JOIN_ROOM - ) { - const chat = decodeMessage?.commentMsg; - // block rendering if is dirty word/user *and* not yourself - if ( - (!chat.isDirty && !chat.isDirtyWord && !chat.isDirtyUser) || - (chat.displayUser.userID && - chat.displayUser.userID === userID) - ) { - const indexedChat = prepareIndexedChat(decodeMessage, streamerInfo); - setChatList(prevChatList => { - const newChatList = [...prevChatList, indexedChat]; - // only keep the last 1000 chat messages - return newChatList.length > 1000 ? newChatList.slice(-1000) : newChatList; - }); - } - } else if (decodeMessage?.type === MsgType_NEW_GIFT - || decodeMessage?.type === MsgType_NEW_LUCKYBAG - || decodeMessage?.type === MsgType_AI_COHOST_MESSAGE - || decodeMessage?.type === MsgType_POKE - ) { - const indexedChat = prepareIndexedChat(decodeMessage); - setChatList(prevChatList => { - const newChatList = [...prevChatList, indexedChat]; - // only keep the last 1000 chat messages - return newChatList.length > 1000 ? newChatList.slice(-1000) : newChatList; - }); - } - }); - - // Cleanup on unmount - return () => { - channel.unsubscribe(); - }; - }, [roomID, userID, roomInfo]); - - return ( - - {chatList.length === 0 ? ( -
- - {t('EMPTY_CHAT_MESSAGE')} -
- ) : ( - chatList.map(chat => ( - - )) - )} -
- - ); -} diff --git a/web/ably_chat/src/app/[locale]/favicon.ico b/web/ably_chat/src/app/[locale]/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/web/ably_chat/src/app/[locale]/favicon.ico and /dev/null differ diff --git a/web/ably_chat/src/app/[locale]/layout.js b/web/ably_chat/src/app/[locale]/layout.js index 17df801..d3ddfee 100644 --- a/web/ably_chat/src/app/[locale]/layout.js +++ b/web/ably_chat/src/app/[locale]/layout.js @@ -2,6 +2,7 @@ import { NextIntlClientProvider, hasLocale } from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import { notFound } from 'next/navigation'; import { routing } from '@/i18n/routing'; +import StyledComponentsProvider from '@/styles/StyledComponentsProvider'; export const metadata = { title: '17Live Chatroom', @@ -21,11 +22,13 @@ export default async function RootLayout({ children, params }) { setRequestLocale(locale); return ( - + - - {children} - + + + {children} + + ); diff --git a/web/ably_chat/src/app/[locale]/page.js b/web/ably_chat/src/app/[locale]/page.js index ddce15a..a58e76a 100644 --- a/web/ably_chat/src/app/[locale]/page.js +++ b/web/ably_chat/src/app/[locale]/page.js @@ -1,13 +1,53 @@ -import Ably from './Ably'; -import {setRequestLocale} from 'next-intl/server'; -export default async function Home({params}) { - const { locale } = await params; - - setRequestLocale(locale); +'use client'; +import styled from 'styled-components'; +import MultiPlatformChat from '@/components/MultiPlatformChat'; +import { useEffect } from 'react'; +import { wsManager } from '@/services/WebSocketManager'; + +const PageContainer = styled.div` + min-height: 100vh; + background-color: #f9fafb; + &.dark { + background-color: #111827; + } +`; + +export default function Home({params}) { + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const wsParam = params.get('ws'); + + if (wsParam && wsParam.trim()) { + console.log('WebSocket: found `ws` parameter, attempting to connect...'); + const onOpen = ({ url }) => console.log('WebSocket connected:', url); + const onClose = ({ url }) => console.log('WebSocket closed:', url); + const onError = (err) => console.error('WebSocket error:', err); + + wsManager.on('open', onOpen); + wsManager.on('close', onClose); + wsManager.on('error', onError); + + wsManager.connect().then((url) => { + console.log('WebSocket connect attempt started:', url); + }).catch((err) => { + console.error('WebSocket connect failed:', err); + }); + + return () => { + wsManager.off('open', onOpen); + wsManager.off('close', onClose); + wsManager.off('error', onError); + }; + } else { + console.log('WebSocket: `ws` parameter missing, connection not started.'); + } + }, []); + return ( -
- -
+ + + ); } diff --git a/web/ably_chat/src/components/MultiPlatformChat.jsx b/web/ably_chat/src/components/MultiPlatformChat.jsx new file mode 100644 index 0000000..bc1453d --- /dev/null +++ b/web/ably_chat/src/components/MultiPlatformChat.jsx @@ -0,0 +1,207 @@ +"use client"; +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import styled from 'styled-components'; +import { messageAggregator } from '../services/MessageAggregator'; +import { PlatformSelector } from './PlatformSelector'; +import Chat from '@/lib/Chat'; +import { getChatProps } from '@/platforms/17live/util/getChatProps'; +import { fromJS } from 'immutable'; + +/** + * Multi-platform message display component + * Aggregates and displays messages from different platforms + */ + +const Container = styled.div` + min-height: 100vh; + background-color: #000000; + color: #f3f4f6; +`; + +const Header = styled.div` + padding: 1rem; + border-bottom: 1px solid #1f2937; +`; + +const HeaderContent = styled.div` + max-width: 28rem; +`; + +const MessageList = styled.div` + height: calc(100vh - 72px); + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + position: relative; + /* Remove default bullets and padding inside nested lists */ + & ul, + & ol { + list-style: none; + margin: 0; + padding-left: 0; + } +`; + +const EmptyState = styled.div` + position: absolute; + top: 33%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0; + color: #A1A9B6; + font-size: 14px; +`; + +const EmptyIcon = styled.img` + width: 80px; + height: 80px; + margin-bottom: 12px; +`; + +const MessageItem = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; +`; + +const PlatformIcon = styled.img` + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + flex-shrink: 0; +`; + +const MessageContent = styled.div` + flex: 1; +`; + +const SimpleMessage = styled.div` + font-size: 0.875rem; +`; + +const Username = styled.span` + font-weight: 600; + margin-right: 0.5rem; +`; + +export const MultiPlatformChat = () => { + const [messages, setMessages] = useState([]); // Raw unified message format + const [selectedPlatform, setSelectedPlatform] = useState('all'); + const [filteredMessages, setFilteredMessages] = useState([]); + const t = useTranslations('ChatPage'); + const listRef = useRef(null); + const endRef = useRef(null); + + // Listen to message aggregator events (directly using unified format) + useEffect(() => { + if (!messageAggregator) return; + const initial = typeof messageAggregator.getHistory === 'function' ? messageAggregator.getHistory(1000) : []; + if (initial && initial.length) { + setMessages(prev => { + const next = [...prev, ...initial]; + return next.length > 1000 ? next.slice(-1000) : next; + }); + } + // Consume only batch events to avoid duplicate inserts + const handleMessagesBatch = (batch) => { + if (!batch || batch.length === 0) return; + setMessages(prev => { + const next = [...prev, ...batch]; + return next.length > 1000 ? next.slice(-1000) : next; + }); + }; + messageAggregator.on('messages_batch', handleMessagesBatch); + + return () => { + messageAggregator.off('messages_batch', handleMessagesBatch); + }; + }, []); + + const handleSelectionChange = (value) => { + setSelectedPlatform(value); + }; + + // Filter messages based on selected platform + useEffect(() => { + const next = messages.filter(m => selectedPlatform === 'all' || m.platform === selectedPlatform); + setFilteredMessages(next); + }, [messages, selectedPlatform]); + + // Scroll to the latest message when content exceeds the viewport + useEffect(() => { + const el = listRef.current; + if (!el) return; + const exceedsViewport = el.scrollHeight > el.clientHeight; + if (exceedsViewport) { + el.scrollTop = el.scrollHeight; + // Alternatively, ensure the sentinel is visible + endRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); + } + }, [filteredMessages]); + + // Platform icon mapping + const platformIcon = (platform) => { + switch (platform) { + case '17live': + return '/images/17live.svg'; + case 'twitch': + return '/images/twitch.svg'; + default: + return '/images/17live.svg'; + } + }; + + // Platform message unification: directly use content as Immutable object + + // Render a single message (Chat component + platform icon) + const renderMessageItem = (message, index) => { + const immutableChat = message.content; // Immutable content generated by platform + const chatProps = getChatProps(immutableChat); + const { key: _key, ...safeChatProps } = chatProps || {}; + return ( + + + + + + + ); + }; + + return ( + + {/* Top selector */} +
+ + + +
+ + {/* Message list */} + + {filteredMessages.length === 0 ? ( + + + {t('EMPTY_CHAT_MESSAGE')} + + ) : ( + filteredMessages.map((m, i) => renderMessageItem(m, i)) + )} +
+ + + ); +}; + +export default MultiPlatformChat; diff --git a/web/ably_chat/src/components/PlatformSelector.jsx b/web/ably_chat/src/components/PlatformSelector.jsx new file mode 100644 index 0000000..ffac31b --- /dev/null +++ b/web/ably_chat/src/components/PlatformSelector.jsx @@ -0,0 +1,279 @@ +"use client"; +import React, { useState, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { useTranslations } from 'next-intl'; + +const Wrapper = styled.div` + position: relative; + /* Adaptive width to avoid multilingual text overflow */ + width: auto; + min-width: 162px; + max-width: 320px; +`; + +const Trigger = styled.button` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 4px; + isolation: isolate; + width: 100%; + height: 32px; + padding: 6px 12px; + /* Background color set to #3C404C to improve text contrast */ + background: #3C404C; + border-radius: 6px; + border: none; + outline: none; + color: #A1A9B6; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + font-weight: 400; + font-size: 14px; + line-height: 20px; + position: relative; + cursor: pointer; +`; + +const LabelText = styled.span` + flex: 1; + text-align: left; + /* Long text truncation with ellipsis */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RightChevron = styled.span` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%) rotate(${(p) => (p.$open ? '-135deg' : '45deg')}); + transition: transform 150ms ease; + width: 8px; + height: 8px; + border-bottom: 1px solid #B3B3B3; + border-right: 1px solid #B3B3B3; + pointer-events: none; +`; + +const Popover = styled.div` + position: absolute; + top: calc(100% + 8px); + left: 0; + width: 100%; + background: #3C404C; + border: 1px solid #3C404C; + border-radius: 8px; + padding: 8px; + z-index: 50; +`; + +const List = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const Item = styled.button` + width: 100%; + text-align: left; + background: transparent; + border: none; + border-radius: 6px; + padding: 6px 8px; + /* Unified text color and font size */ + color: #A1A9B6; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + font-weight: 400; + font-size: 14px; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + &:hover { background: #4A4F5D; } + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +const StatusDot = styled.span` + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin: 0 6px 0 6px; + background: ${(p) => (p.$connected ? '#00D22E' : '#A1A9B6')}; +`; + +const NameLabel = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StatusWrap = styled.span` + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + color: #A1A9B6; +`; + +const SelectedStatusWrap = styled.span` + display: inline-flex; + align-items: center; + gap: 6px; + color: #A1A9B6; + margin-left: auto; + padding-right: 24px; +`; + +export const PlatformSelector = ({ onSelectionChange, messageAggregator }) => { + const t = useTranslations('PlatformSelector'); + const [platformsStatus, setPlatformsStatus] = useState({}); + const [selected, setSelected] = useState('all'); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + + const platformDefs = [ + { id: 'all', name: t('platforms.all') }, + { id: '17live', name: t('platforms.17live') }, + { id: 'twitch', name: t('platforms.twitch') }, + ]; + + const statusText = (platformId) => { + if (platformId === 'all') return ''; + const isConnected = platformsStatus[platformId]?.status === 'connected'; + const status = isConnected ? t('status.connected') : t('status.disconnected'); + return t('status.format', { status }); + }; + + const updateSelection = (value) => { + setSelected(value); + setOpen(false); + // Notify parent of selection change for unified filtering updates + if (onSelectionChange) { + onSelectionChange(value); + } + }; + + const connectPlatform = async (platformId, config = {}) => { + if (!messageAggregator) return; + try { + const statusMap = messageAggregator.getPlatformsStatus?.(); + if (!statusMap || !statusMap[platformId]) { + await messageAggregator.addPlatform(platformId, config); + } + await messageAggregator.connectPlatform(platformId, config); + } catch (error) { + console.error(`Failed to connect ${platformId}:`, error); + } + }; + + useEffect(() => { + if (!messageAggregator) return; + + const handleStatusChange = ({ platformId, status }) => { + setPlatformsStatus(prev => ({ + ...prev, + [platformId]: { ...(prev[platformId] || {}), status }, + })); + }; + + messageAggregator.on('status_change', handleStatusChange); + + (async () => { + // Create instances for youtube and twitch to trigger mock injection via constructors. + // Keep them disconnected in UI; only 17live is connected by default. + try { + const statusMap = messageAggregator.getPlatformsStatus?.(); + if (!statusMap || !statusMap['twitch']) { + await messageAggregator.addPlatform('twitch', {}); + } + if (!statusMap || !statusMap['17live']) { + await messageAggregator.addPlatform('17live', {}); + } + } catch { } + + // Connect 17live only; youtube and twitch remain disconnected but instances exist. + await connectPlatform('17live', {}); + + // Explicitly mark twitch as disconnected for status display, without connecting it. + setPlatformsStatus(prev => ({ + ...prev, + twitch: { ...(prev.twitch || {}), status: 'disconnected' }, + })); + + updateSelection('all'); + })(); + + return () => { + messageAggregator.off('status_change', handleStatusChange); + }; + }, [messageAggregator]); + + // Close on outside click and ESC + useEffect(() => { + const handleClickOutside = (e) => { + if (!open) return; + const t = triggerRef.current; + const p = popoverRef.current; + if (t && t.contains(e.target)) return; + if (p && p.contains(e.target)) return; + setOpen(false); + }; + const handleKey = (e) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keyup', handleKey); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keyup', handleKey); + }; + }, [open]); + + return ( + + setOpen((prev) => !prev)} + ref={triggerRef} + > + {platformDefs.find(d => d.id === selected)?.name || t('platforms.all')} + {selected !== 'all' && ( + + + {statusText(selected)} + + )} + + {open && ( + + + {platformDefs.map((p) => ( + updateSelection(p.id)}> + {p.name} + {p.id !== 'all' && ( + + + {statusText(p.id)} + + )} + + ))} + + + )} + + ); +}; + +export default PlatformSelector; diff --git a/web/ably_chat/src/lib/Box.jsx b/web/ably_chat/src/lib/Box.jsx deleted file mode 100644 index e57e4a1..0000000 --- a/web/ably_chat/src/lib/Box.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import styled from 'styled-components'; - -import { - background, - border, - color, - flexbox, - fontSize, - layout, - position, - shadow, - space, - textAlign, -} from 'styled-system'; - -const Box = styled.div` - ${fontSize} - ${textAlign} - ${border} - ${color} - ${space} - ${layout} - ${position} - ${shadow} - ${flexbox} - ${background} -`; - -export default Box; \ No newline at end of file diff --git a/web/ably_chat/src/lib/Chat.jsx b/web/ably_chat/src/lib/Chat.jsx index d8ea16a..bfa3305 100644 --- a/web/ably_chat/src/lib/Chat.jsx +++ b/web/ably_chat/src/lib/Chat.jsx @@ -1,6 +1,7 @@ import React, { memo } from 'react'; import styled from 'styled-components'; +import { useTranslations } from 'next-intl'; import Multiline from './Multiline'; import SVG from './SVG'; @@ -12,7 +13,6 @@ import BadgeImage from './BadgeImage'; import ChatWrapper from './ChatWrapper'; import InnerWrapper from './InnerWrapper'; import useComment from './hooks'; -import Box from './Box'; import GiftItem from './GiftItem'; import PokeItem from './PokeItem'; @@ -46,7 +46,7 @@ const MultilineDesktop = styled(Multiline)` color: ${({ color }) => color}; `; -const renderMessageContent = (messageType, content, gift = null, luckyBag = null, pokeInfo = null, streamerInfo = null) => { +const renderMessageContent = (messageType, content, gift = null, giftPoint = null, luckyBag = null, pokeInfo = null, streamerInfo = null) => { switch (messageType) { case MsgType_COMMENT: case MsgType_JOIN_ROOM: @@ -54,7 +54,7 @@ const renderMessageContent = (messageType, content, gift = null, luckyBag = null return content; case MsgType_NEW_GIFT: case MsgType_NEW_LUCKYBAG: - return ; + return ; case MsgType_POKE: return ; default: @@ -93,7 +93,10 @@ const Chat = ({ gift, luckyBag, pokeInfo, + giftPoint, }) => { + const t = useTranslations('ChatPage'); + const { commentRef, size, @@ -157,16 +160,16 @@ const Chat = ({ > {levelBadges?.map(badge => ( + {prefixBadgeContents} - + )} {/* AI Cohost avatar */} @@ -205,7 +208,7 @@ const Chat = ({ levelBadges={levelBadges} isConcert={isConcert} openID={openID || ''} - displayName={displayName || ''} + displayName={isAiCohost ? t('AI_COHOST') : displayName || ''} streamerInfo={streamerInfo} userID={userID} roomID={roomID} @@ -216,12 +219,14 @@ const Chat = ({ {middleBadge && } {SVGSrc && ( - - + )} - {renderMessageContent(messageType, content, gift, luckyBag, pokeInfo, streamerInfo)} + {renderMessageContent(messageType, content, gift, giftPoint, luckyBag, pokeInfo, streamerInfo)} {/* Top right badge */} {hasTopRightBadge && ( - +
- +
)}
diff --git a/web/ably_chat/src/lib/ChatUserName.jsx b/web/ably_chat/src/lib/ChatUserName.jsx index 18cabca..c5951c1 100644 --- a/web/ably_chat/src/lib/ChatUserName.jsx +++ b/web/ably_chat/src/lib/ChatUserName.jsx @@ -11,7 +11,7 @@ import IconWrapper from './IconWrapper'; import StreamerPicture from './StreamerPicture'; export const NameWrapper = styled.span` - color: ${props => props.nameColor || mapLevelToTextColor(props.level)}; + color: ${props => props.$nameColor || mapLevelToTextColor(props.$level)}; cursor: pointer; `; @@ -29,8 +29,8 @@ const ChatUserName = ({ ) : ( {openID || displayName} diff --git a/web/ably_chat/src/lib/ChatWrapper.jsx b/web/ably_chat/src/lib/ChatWrapper.jsx index 1df69ab..23919fa 100644 --- a/web/ably_chat/src/lib/ChatWrapper.jsx +++ b/web/ably_chat/src/lib/ChatWrapper.jsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import { LevelBadgeWrapper } from './LevelBadgeBase'; import { BD_WHITE } from './constants'; -const ChatWrapper = styled.li` +const ChatWrapper = styled.div` margin-bottom: 4px; font-size: 14px; overflow-wrap: break-word; diff --git a/web/ably_chat/src/lib/CheckingLevelBadge.jsx b/web/ably_chat/src/lib/CheckingLevelBadge.jsx index 7f03e3e..a0aec7d 100644 --- a/web/ably_chat/src/lib/CheckingLevelBadge.jsx +++ b/web/ably_chat/src/lib/CheckingLevelBadge.jsx @@ -6,8 +6,8 @@ import { mapCheckingLevelImage } from './constants'; import { getCheckingLevelImage, getWebp2xURL } from './utils'; const CheckingLevelWrapper = styled.span` - margin-left: ${props => props.marginLeft || 0}px; - margin-right: ${props => props.marginRight || 0}px; + margin-left: ${props => props.$marginLeft || 0}px; + margin-right: ${props => props.$marginRight || 0}px; vertical-align: middle; margin-top: -2px; display: inline-flex; @@ -31,10 +31,10 @@ const CheckingLevelBadge = ({ } return ( - + ); }; -export default memo(CheckingLevelBadge); \ No newline at end of file +export default memo(CheckingLevelBadge); diff --git a/web/ably_chat/src/lib/GiftItem.jsx b/web/ably_chat/src/lib/GiftItem.jsx index 601dc64..f32b6e5 100644 --- a/web/ably_chat/src/lib/GiftItem.jsx +++ b/web/ably_chat/src/lib/GiftItem.jsx @@ -1,17 +1,42 @@ import React from 'react'; -import GiftIcon from './GiftIcon'; // 假设 GiftIcon.jsx 在同一目录下 +import styled from 'styled-components'; +import GiftIcon from './GiftIcon'; // Assume GiftIcon.jsx is in the same directory import { useTranslations } from 'next-intl'; import { MsgType_NEW_LUCKYBAG } from './constants'; -const GiftItem = ({ messageType, giftInfo, luckyBagInfo }) => { +const GiftItemContainer = styled.span` + display: inline-flex; + align-items: center; + gap: 0.25rem; +`; + +const GiftName = styled.span` + font-weight: 500; + color: #f59e0b; +`; + +const GiftPoint = styled.span` + color: #6b7280; + font-size: 0.875rem; +`; + +const GiftItem = ({ messageType, giftInfo, giftPoint, luckyBagInfo }) => { const t = useTranslations('ChatPage'); if (!giftInfo) { - return null; + return ( + + <> + {t('GIVE_GIFT_DEFAULT', { + point: giftPoint + })} + + + ) } if (messageType === MsgType_NEW_LUCKYBAG && !luckyBagInfo) { - return null; + messageType = MsgType_NEW_GIFT; // Default to gift if lucky bag info is missing } const name = giftInfo.get('name'); @@ -19,24 +44,24 @@ const GiftItem = ({ messageType, giftInfo, luckyBagInfo }) => { const icon = giftInfo.get('icon'); return ( - - {messageType === MsgType_NEW_LUCKYBAG ? + + {messageType === MsgType_NEW_LUCKYBAG ? t('GIVE_LUCKYBAG_GIFT', { giftName: name, luckyBagName: luckyBagInfo.get('name'), point - }) - : + }) + : ( <> {t('GIVE_GIFT')} - {name} - ({point}) + {name} + ({point}) ) } - + ); }; diff --git a/web/ably_chat/src/lib/InnerWrapper.jsx b/web/ably_chat/src/lib/InnerWrapper.jsx index 7465e59..bb58df3 100644 --- a/web/ably_chat/src/lib/InnerWrapper.jsx +++ b/web/ably_chat/src/lib/InnerWrapper.jsx @@ -3,25 +3,25 @@ import styled, { css } from 'styled-components'; import { mapReactionToBackgroundColor, mapUserTypeToBackgroundColor } from './utils'; const userDecorationCss = css` - background-color: ${({ backgroundColor, userType, reactionType }) => - backgroundColor || - mapUserTypeToBackgroundColor(userType) || - (reactionType !== undefined && mapReactionToBackgroundColor(reactionType))}; + background-color: ${({ $backgroundColor, $userType, $reactionType }) => + $backgroundColor || + mapUserTypeToBackgroundColor($userType) || + ($reactionType !== undefined && mapReactionToBackgroundColor($reactionType))}; - ${({ textShadowColor }) => - textShadowColor && `text-shadow: 1px 1px 0.5px ${textShadowColor};`} + ${({ $textShadowColor }) => + $textShadowColor && `text-shadow: 1px 1px 0.5px ${$textShadowColor};`} `; const InnerWrapper = styled.div` position: relative; word-break: break-all; - width: ${({ isFullWidth }) => (isFullWidth ? '100%' : 'fit-content')}; + width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : 'fit-content')}; padding: 5px 8px; - ${({ hasPaddingRight }) => hasPaddingRight && 'padding-right: 30px;'} + ${({ $hasPaddingRight }) => $hasPaddingRight && 'padding-right: 30px;'} line-height: 24px; - border-radius: ${({ borderRadius }) => `${borderRadius || 8}px`}; + border-radius: ${({ $borderRadius }) => `${$borderRadius || 8}px`}; - ${({ hasUserDecoration }) => hasUserDecoration && userDecorationCss} + ${({ $hasUserDecoration }) => $hasUserDecoration && userDecorationCss} `; -export default InnerWrapper; \ No newline at end of file +export default InnerWrapper; diff --git a/web/ably_chat/src/lib/LevelBadgeBase.jsx b/web/ably_chat/src/lib/LevelBadgeBase.jsx index fbc74cc..b93890c 100644 --- a/web/ably_chat/src/lib/LevelBadgeBase.jsx +++ b/web/ably_chat/src/lib/LevelBadgeBase.jsx @@ -7,7 +7,7 @@ import { normalizeLevel } from './utils'; export const LevelBadgeWrapper = styled.span.attrs(props => ({ style: { - background: LEVEL_COLORS[`LEVEL_${props.normalizedLevel}`], + background: LEVEL_COLORS[`LEVEL_${props.$normalizedLevel}`], }, }))` display: inline-flex; @@ -21,7 +21,7 @@ export const LevelBadgeWrapper = styled.span.attrs(props => ({ const LevelBadgeBase = ({ level, className }) => ( {level} diff --git a/web/ably_chat/src/lib/LevelIcon.jsx b/web/ably_chat/src/lib/LevelIcon.jsx index c3d7f6c..8716588 100644 --- a/web/ably_chat/src/lib/LevelIcon.jsx +++ b/web/ably_chat/src/lib/LevelIcon.jsx @@ -15,8 +15,8 @@ import { export const LevelIconWrapper = styled(SVG).attrs(props => ({ style: { background: props.isIcon - ? mapLevelToIconBackground(props.normalizedLevel) - : LEVEL_COLORS[`LEVEL_${props.normalizedLevel}`], + ? mapLevelToIconBackground(props.$normalizedLevel) + : LEVEL_COLORS[`LEVEL_${props.$normalizedLevel}`], }, }))` display: inline-flex; @@ -95,7 +95,7 @@ class LevelIcon extends PureComponent { className={className} key={src} src={src || defaultSVG} - normalizedLevel={normalizeLevel(level)} + $normalizedLevel={normalizeLevel(level)} isIcon={isIcon} /> ); diff --git a/web/ably_chat/src/lib/constants.js b/web/ably_chat/src/lib/constants.js index 131f3f9..a0cc5c5 100644 --- a/web/ably_chat/src/lib/constants.js +++ b/web/ably_chat/src/lib/constants.js @@ -196,5 +196,6 @@ export const MsgType_COMMENT = 3; // General comment message export const MsgType_NEW_GIFT =13; // Gift animation message export const MsgType_JOIN_ROOM = 18; // Audience join room message export const MsgType_NEW_LUCKYBAG = 32; // Random gift message -export const MsgType_AI_COHOST_MESSAGE = 120; // AI co-host message export const MsgType_POKE = 47; // Poke message +export const MsgType_ROCKZONE = 74; // Rock Zone message +export const MsgType_AI_COHOST_MESSAGE = 120; // AI co-host message diff --git a/web/ably_chat/src/lib/hooks.js b/web/ably_chat/src/lib/hooks.js index 43e5c15..e96ed34 100644 --- a/web/ably_chat/src/lib/hooks.js +++ b/web/ably_chat/src/lib/hooks.js @@ -7,13 +7,13 @@ import React, { useState, } from 'react'; -import { isImmutable } from 'immutable-v4'; +// Immutable v3 does not provide isImmutable; use generic toJS detection instead import BadgeImage from './BadgeImage'; const transformImmutable = item => { - if (isImmutable(item)) { + if (item && typeof item.toJS === 'function') { return item.toJS(); } return item; diff --git a/web/ably_chat/src/lib/utils.js b/web/ably_chat/src/lib/utils.js index 7fd9aab..0c31774 100644 --- a/web/ably_chat/src/lib/utils.js +++ b/web/ably_chat/src/lib/utils.js @@ -1,7 +1,43 @@ import padStart from 'lodash/padStart'; import isArray from 'lodash/isArray'; import range from 'lodash/range'; -import { rgba } from 'polished'; +// Local rgba helper to avoid polished dependency +const rgba = (input, alpha = 1) => { + if (typeof input !== 'string') return input; + const trim = input.trim(); + // If already rgba, return as is + if (trim.startsWith('rgba(')) return trim; + // rgb(r,g,b) -> rgba(r,g,b,a) + if (trim.startsWith('rgb(')) { + const nums = trim + .slice(4, -1) + .split(',') + .map(v => parseInt(v.trim(), 10)); + if (nums.length === 3) { + const [r, g, b] = nums; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return trim; + } + // #rrggbb or #rgb + if (trim.startsWith('#')) { + let r, g, b; + if (trim.length === 7) { + r = parseInt(trim.slice(1, 3), 16); + g = parseInt(trim.slice(3, 5), 16); + b = parseInt(trim.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } else if (trim.length === 4) { + r = parseInt(trim[1] + trim[1], 16); + g = parseInt(trim[2] + trim[2], 16); + b = parseInt(trim[3] + trim[3], 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return trim; + } + // Fallback: return original string + return trim; +}; import { CDN_URL, @@ -461,7 +497,13 @@ export const getUserType = ( streamerInfo ) => { // this order is important (streamer -> guardian -> vip -> normal) - if (streamerInfo && user.userID === streamerInfo.get('userID')) { + const streamerId = streamerInfo + ? (typeof streamerInfo.get === 'function' + ? streamerInfo.get('userID') + : streamerInfo.userID) + : undefined; + + if (streamerId && user?.userID === streamerId) { return USER_STREAMER; } else if (user.isGuardian) { return USER_GUARDIAN; @@ -532,4 +574,4 @@ export const getWebp2xURL = (src, { has2x = true } = {}) => { const extName = window.Modernizr?.webp ? `webp` : ext; return `${base}${ratio}.${extName}`; -}; \ No newline at end of file +}; diff --git a/web/ably_chat/src/platforms/17live/api/gifts.js b/web/ably_chat/src/platforms/17live/api/gifts.js new file mode 100644 index 0000000..0a57946 --- /dev/null +++ b/web/ably_chat/src/platforms/17live/api/gifts.js @@ -0,0 +1,112 @@ +// Used to store gift information +let giftsMap = new Map(); + +async function loadMockGifts() { + try { + // In development environment, read gift information from local JSON file + const response = await fetch('/mock/get_gifts_response.json'); + if (!response.ok) { + throw new Error(`Failed to fetch gifts: ${response.status}`); + } + const giftsData = await response.json(); + if (giftsData && giftsData.gifts) { + giftsData.gifts.forEach(gift => { + giftsMap.set(gift.giftID, gift); + }); + // console.log('Gifts loaded from local JSON:', giftsMap.size); + } else { + console.error('Invalid gifts data structure in local JSON'); + } + } catch (error) { + console.error('Error loading gifts from local JSON:', error); + } +} + +if (process.env.NODE_ENV === 'development') { + loadMockGifts(); +} + +export async function getGifts() { + if (process.env.NODE_ENV === 'development') { + await loadMockGifts(); + } else { + const url = `/lapi`; + const data = { + action: 'getGifts', + } + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + }); + + if (!res.ok) { + throw new Error(`Failed to fetch gifts from server: ${res.status}`); + } + + const giftsData = await res.json(); + if (giftsData && giftsData.gifts) { + giftsData.gifts.forEach(gift => { + giftsMap.set(gift.giftID, gift); + }); + console.log('Gifts loaded from server:', giftsMap.size); + } else { + console.error('Invalid gifts data structure from server'); + } + } catch (err) { + console.error('Error loading gifts from server:', err); + throw err; + } + } +} + +export async function getGiftByID(giftID) { + if (!giftID) return null; + + // Check if gift information is already loaded + if (giftsMap.has(giftID)) { + // console.log('Gift already loaded:', giftID); + return giftsMap.get(giftID); + } + + // If not development environment, try to fetch from server + if (process.env.NODE_ENV !== 'development') { + const url = `/lapi`; + const data = { + action: 'getGift', + giftID: giftID + } + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + }); + + if (!res.ok) { + console.warn(`Failed to fetch gift ${giftID}: ${res.status}`); + return null; + } + + const giftData = await res.json(); + if (giftData && giftData.giftID) { + giftsMap.set(giftData.giftID, giftData); + console.log('Gift loaded from server:', giftData.giftID); + return giftData; + } else { + console.warn('Invalid gift data structure from server:', giftData); + return null; + } + } catch (err) { + console.error('Error loading gift from server:', err); + return null; + } + } + + return null; +} diff --git a/web/ably_chat/src/platforms/17live/api/index.js b/web/ably_chat/src/platforms/17live/api/index.js new file mode 100644 index 0000000..0a7daad --- /dev/null +++ b/web/ably_chat/src/platforms/17live/api/index.js @@ -0,0 +1,2 @@ +export * from './room'; +export * from './gifts'; \ No newline at end of file diff --git a/web/ably_chat/src/api/room.js b/web/ably_chat/src/platforms/17live/api/room.js similarity index 100% rename from web/ably_chat/src/api/room.js rename to web/ably_chat/src/platforms/17live/api/room.js diff --git a/web/ably_chat/src/platforms/17live/components/OneSevenLiveMessage.jsx b/web/ably_chat/src/platforms/17live/components/OneSevenLiveMessage.jsx new file mode 100644 index 0000000..2ad1bd7 --- /dev/null +++ b/web/ably_chat/src/platforms/17live/components/OneSevenLiveMessage.jsx @@ -0,0 +1,164 @@ +/** + * 17Live platform message UI component + * Dedicated to rendering messages from the 17Live platform + */ + +import React, { memo } from 'react'; +import { fromJS } from 'immutable'; +import styled from 'styled-components'; +import Chat from '@/lib/Chat'; +import { getChatProps } from '@/platforms/17live/util/getChatProps'; +import { + MsgType_COMMENT, + MsgType_NEW_GIFT, + MsgType_JOIN_ROOM, + MsgType_AI_COHOST_MESSAGE, + MsgType_POKE, + MsgType_NEW_LUCKYBAG, +} from '@/lib/constants'; + +const PlatformIcon = styled.img` + width: 16px; + height: 16px; + margin-right: 8px; + border-radius: 50%; +`; + +const MessageWrapper = styled.div` + display: flex; + align-items: flex-start; + margin-bottom: 4px; + position: relative; +`; + +const PlatformBadge = styled.div` + position: absolute; + left: -20px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +`; + +export const OneSevenLiveMessage = memo(({ message, streamerInfo, asideLiveWidth }) => { + const { + id, + type, + content, + author, + metadata, + rawData + } = message; + + // Convert message format to be compatible with the existing Chat component + const chatProps = convertToChatProps(message, streamerInfo); + + return ( + + + + +
+ +
+
+ ); +}); + +/** + * Convert unified message format to props required by the Chat component + */ +export function convertToChatProps(message, streamerInfo) { + const { id, type, content, author, metadata, rawData } = message; + + const baseProps = { + id, + messageType: getMessageType(type), + content, + streamerInfo: metadata?.streamerInfo || streamerInfo, + }; + + switch (type) { + case 'comment': + return fromJS({ + ...baseProps, + userID: author.id, + displayName: author.displayName, + name: { textColor: metadata?.textColor || '#333333' }, + comment: { textColor: metadata?.textColor || '#333333' }, + backgroundColor: metadata?.backgroundColor || '', + level: author.level || 1, + isStreamer: author.isStreamer || false, + openID: author.id, + }); + + case 'gift': + const giftData = rawData?.giftMsg || {}; + return fromJS({ + ...baseProps, + userID: author.id, + displayName: author.displayName, + gift: metadata?.gift, + luckyBag: metadata?.luckyBag, + messageType: metadata?.luckyBag ? MsgType_NEW_LUCKYBAG : MsgType_NEW_GIFT, + }); + + case 'join': + return fromJS({ + ...baseProps, + userID: author.id, + displayName: author.displayName, + name: { textColor: metadata?.textColor || '#333333' }, + comment: { textColor: metadata?.textColor || '#333333' }, + backgroundColor: metadata?.backgroundColor || '', + messageType: MsgType_JOIN_ROOM, + }); + + case 'ai_cohost': + return fromJS({ + ...baseProps, + userID: 'ai_cohost', + displayName: author.displayName, + messageType: MsgType_AI_COHOST_MESSAGE, + backgroundColor: metadata?.backgroundColor || '#FFFFFFE6', + name: { textColor: '#527fff' }, + comment: { textColor: metadata?.textColor || '#333333' }, + }); + + case 'poke': + return fromJS({ + ...baseProps, + userID: author.id, + displayName: author.displayName, + messageType: MsgType_POKE, + pokeInfo: metadata?.pokeInfo, + isStreamer: author.isStreamer || false, + }); + + default: + return fromJS(baseProps); + } +} + +function getMessageType(type) { + const typeMap = { + comment: MsgType_COMMENT, + gift: MsgType_NEW_GIFT, + join: MsgType_JOIN_ROOM, + ai_cohost: MsgType_AI_COHOST_MESSAGE, + poke: MsgType_POKE, + }; + return typeMap[type] || MsgType_COMMENT; +} + +export default OneSevenLiveMessage; \ No newline at end of file diff --git a/web/ably_chat/src/platforms/17live/core/OneSevenLivePlatform.js b/web/ably_chat/src/platforms/17live/core/OneSevenLivePlatform.js new file mode 100644 index 0000000..864a9d9 --- /dev/null +++ b/web/ably_chat/src/platforms/17live/core/OneSevenLivePlatform.js @@ -0,0 +1,282 @@ +/** + * 17Live Platform Handler + * Handles 17Live message connection and processing + */ + +import { BasePlatform } from '../../BasePlatform'; +import { nanoid } from 'nanoid'; +import { fromJS } from 'immutable'; +import { + MsgType_COMMENT, + MsgType_NEW_GIFT, + MsgType_JOIN_ROOM, + MsgType_NEW_LUCKYBAG, + MsgType_AI_COHOST_MESSAGE, + MsgType_POKE, +} from '@/lib/constants'; +import { getGiftByID, getRoomInfo } from '../api'; + +// Dev-only mock messages (same as Ably.jsx) +// import giftdata from '@/../public/mock/chat_new_gift_2.json'; +// import comment from '@/../public/mock/chat_message.json'; +// import newjoin from '@/../public/mock/chat_new_join.json'; +// import aicohost from '@/../public/mock/chat_ai_cohost.json'; +// import pokeone from '@/../public/mock/chat_poke.json'; +// import pokeall from '@/../public/mock/chat_poke_all.json'; +// import pokeback0 from '@/../public/mock/chat_poke_back_0.json'; +// import pokeback1 from '@/../public/mock/chat_poke_back_1.json'; +// import pokeback2 from '@/../public/mock/chat_poke_back_2.json'; +// import pokeback3 from '@/../public/mock/chat_poke_back_3.json'; + +export class OneSevenLivePlatform extends BasePlatform { + constructor() { + super('17live', '17Live'); + this.ablyClient = null; + this.channel = null; + this.roomInfo = null; + this.gifts = null; + this.roomID = ''; + this.userID = ''; + } + + async connect(config = {}) { + try { + let { roomID, userID } = config; + + // Allow fetching roomID/userID from URL when not provided (aligned with Ably.jsx) + if (!roomID || !userID) { + const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''); + roomID = roomID || urlParams.get('roomID') || ''; + userID = userID || urlParams.get('userID') || ''; + } + + // Save connection context + this.roomID = roomID; + this.userID = userID; + + // Fetch room info and gifts + this.roomInfo = await getRoomInfo(); + + this.isConnected = true; + this.emit('connected', { platform: this.platformId, roomID }); + + // if (process.env.NODE_ENV === 'development') { + // const mocks = [ + // this.prepareIndexedChat(comment), + // this.prepareIndexedChat(newjoin), + // this.prepareIndexedChat(giftdata), + // this.prepareIndexedChat(aicohost), + // this.prepareIndexedChat(pokeone), + // this.prepareIndexedChat(pokeall), + // this.prepareIndexedChat(pokeback0), + // this.prepareIndexedChat(pokeback1), + // this.prepareIndexedChat(pokeback2), + // this.prepareIndexedChat(pokeback3), + // ]; + // console.log('mocks', mocks); + // mocks.forEach((mock) => { + // if (mock) { + // const unifiedMessage = { + // id: mock.get('id'), + // platform: this.platformId, + // timestamp: Date.now(), + // content: mock, + // }; + // this.enqueueMessage(unifiedMessage); + // } + // }); + // } + + } catch (error) { + console.error('17Live connection failed:', error); + this.emit('error', { platform: this.platformId, error }); + throw error; + } + } + + async disconnect() { + try { + this.isConnected = false; + this.emit('disconnected', { platform: this.platformId }); + } catch (error) { + console.error('17Live disconnect failed:', error); + throw error; + } + } + + // WebSocket-driven updates (consistent with Twitch/YouTube) + handleWsMessage({ type, payload }) { + if (type === 'ably_chat_connected') { + const status = payload?.status; + const connected = status === 'connected'; + this.isConnected = connected; + if (connected) { + this.emit('connected', { platform: this.platformId, roomID: this.roomID }); + } else { + this.emit('disconnected', { platform: this.platformId }); + } + return; + } + if (type === 'ably_chat_message') { + const decoded = payload; // already decoded server-side + this.processRawMessage(decoded).then(unifiedMessage => { + if (unifiedMessage) this.enqueueMessage(unifiedMessage); + }); + return; + } + } + + decodeMessageData(data) { + // Ably.jsx uses gzip_base64 + pako to decode; platform layer pass-through decoded data + return data; + } + + // Build content consistent with Ably.jsx#prepareIndexedChat; returns an Immutable object + async prepareIndexedChat(message) { + const id = nanoid(); + const streamerInfo = this.roomInfo?.userInfo; + const msgType = typeof message.type !== 'undefined' ? message.type : message?.msgType; + + if (msgType === MsgType_NEW_GIFT || msgType === MsgType_NEW_LUCKYBAG) { + const { displayUser, barrage, ...restGift } = message?.giftMsg || {}; + const gift = await getGiftByID(restGift?.giftID); + + if (msgType === MsgType_NEW_LUCKYBAG && restGift?.extID) { + const luckyBag = await getGiftByID(restGift.extID); + const indexedGift = fromJS({ + ...restGift, + ...(displayUser || {}), + barrage, + id, + messageType: msgType, + gift, + luckyBag, + streamerInfo, + }); + return indexedGift; + } + + const indexedGift = fromJS({ + ...restGift, + ...(displayUser || {}), + barrage, + id, + messageType: msgType, + gift, + streamerInfo, + }); + return indexedGift; + } else if (msgType === MsgType_AI_COHOST_MESSAGE) { + const { commentTxt } = message?.aiCohostMsg || {}; + const indexedChat = fromJS({ + content: commentTxt, + comment: { + textColor: '#333333', + }, + // Use i18n key so UI can resolve translation per locale + displayName: 'AI_COHOST', + name: { + textColor: '#527fff', + }, + backgroundColor: '#FFFFFFE6', + id, + messageType: msgType, + streamerInfo, + }); + return indexedChat; + } else if (msgType === MsgType_POKE) { + const { sender } = message?.pokeInfo || {}; + return fromJS({ + ...(sender || {}), + isStreamer: sender?.userID && streamerInfo?.userID ? sender.userID === streamerInfo.userID : false, + pokeInfo: message?.pokeInfo, + id, + messageType: msgType, + streamerInfo, + }); + } + + const { displayUser, barrage, ...restChat } = message?.commentMsg || {}; + const indexedChat = fromJS({ + ...restChat, + ...(displayUser || {}), + barrage, + id, + messageType: msgType, + streamerInfo, + }); + return indexedChat; + } + + async processRawMessage(rawData) { + const type = typeof rawData?.type !== 'undefined' ? rawData.type : rawData?.msgType; + + switch (type) { + case MsgType_COMMENT: + return this.processCommentMessage(rawData); + case MsgType_NEW_GIFT: + case MsgType_NEW_LUCKYBAG: + return this.processGiftMessage(rawData); + case MsgType_JOIN_ROOM: + return this.processJoinMessage(rawData); + case MsgType_AI_COHOST_MESSAGE: + return this.processAICohostMessage(rawData); + case MsgType_POKE: + return this.processPokeMessage(rawData); + default: + // console.warn('Unknown 17Live message type:', type); + return null; + } + } + + async processCommentMessage(data) { + const content = await this.prepareIndexedChat(data); + return { + id: content.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content, + }; + } + + async processGiftMessage(data) { + const content = await this.prepareIndexedChat(data); + + return { + id: content.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content, + }; + } + + async processJoinMessage(data) { + const content = await this.prepareIndexedChat(data); + return { + id: content.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content, + }; + } + + async processAICohostMessage(data) { + const content = await this.prepareIndexedChat(data); + return { + id: content.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content, + }; + } + + async processPokeMessage(data) { + const content = await this.prepareIndexedChat(data); + return { + id: content.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content, + }; + } +} \ No newline at end of file diff --git a/web/ably_chat/src/util/getAblyDecodeData.js b/web/ably_chat/src/platforms/17live/util/getAblyDecodeData.js similarity index 96% rename from web/ably_chat/src/util/getAblyDecodeData.js rename to web/ably_chat/src/platforms/17live/util/getAblyDecodeData.js index 26bd62b..eb1ca43 100644 --- a/web/ably_chat/src/util/getAblyDecodeData.js +++ b/web/ably_chat/src/platforms/17live/util/getAblyDecodeData.js @@ -1,57 +1,57 @@ -import { ungzip } from 'pako/dist/pako_inflate.min'; - -const mapUnit8ArrayToString = array => - String.fromCodePoint.apply(null, array); - -// After base64 decode, gzip needs to be ungzipped and converted to string -// Then processed through es5 escape, decodeURIComponent, and finally parsed into usable json data -export const decodeMessage = gzipMessage => - [escape, decodeURIComponent, JSON.parse].reduce( - (message, fn) => fn(message), - mapUnit8ArrayToString(ungzip(gzipMessage)) - ); - -export const decodeCompressedData = ( - rawMessage, -) => { - if (!rawMessage.cdata) { - return rawMessage; - } - - let message = rawMessage; - - const { cdata, ...rest } = rawMessage; - - cdata.forEach(({ alg, data = '' }) => { - if (alg === 'gzip_base64') { - try { - // Extract message data separately for decode processing, then merge back with fields other than data into new message - message = { - ...rest, - ...decodeMessage(window.atob(data)), - }; - } catch (e) { - console.error(e.toString()); - } - } - }); - - return message; -}; - -export const getAblyDecodeData = message => { - const { data, ...rest } = message; - const msg = { - cdata: [ - { - alg: 'gzip_base64', - data, - }, - ], - ...rest, - }; - // Since decodeCompressedData applies to both ably and pubnub, input will be formatted into the same format first - const resultData = decodeCompressedData(msg); - - return resultData; +import { ungzip } from 'pako/dist/pako_inflate.min'; + +const mapUnit8ArrayToString = array => + String.fromCodePoint.apply(null, array); + +// After base64 decode, gzip needs to be ungzipped and converted to string +// Then processed through es5 escape, decodeURIComponent, and finally parsed into usable json data +export const decodeMessage = gzipMessage => + [escape, decodeURIComponent, JSON.parse].reduce( + (message, fn) => fn(message), + mapUnit8ArrayToString(ungzip(gzipMessage)) + ); + +export const decodeCompressedData = ( + rawMessage, +) => { + if (!rawMessage.cdata) { + return rawMessage; + } + + let message = rawMessage; + + const { cdata, ...rest } = rawMessage; + + cdata.forEach(({ alg, data = '' }) => { + if (alg === 'gzip_base64') { + try { + // Extract message data separately for decode processing, then merge back with fields other than data into new message + message = { + ...rest, + ...decodeMessage(window.atob(data)), + }; + } catch (e) { + console.error(e.toString()); + } + } + }); + + return message; +}; + +export const getAblyDecodeData = message => { + const { data, ...rest } = message; + const msg = { + cdata: [ + { + alg: 'gzip_base64', + data, + }, + ], + ...rest, + }; + // Since decodeCompressedData applies to both ably and pubnub, input will be formatted into the same format first + const resultData = decodeCompressedData(msg); + + return resultData; }; \ No newline at end of file diff --git a/web/ably_chat/src/util/getChatProps.js b/web/ably_chat/src/platforms/17live/util/getChatProps.js similarity index 98% rename from web/ably_chat/src/util/getChatProps.js rename to web/ably_chat/src/platforms/17live/util/getChatProps.js index 514dfce..1e2e2d2 100644 --- a/web/ably_chat/src/util/getChatProps.js +++ b/web/ably_chat/src/platforms/17live/util/getChatProps.js @@ -29,6 +29,7 @@ export const getChatProps = chat => { type: chat.get('type'), checkingLevel: chat.get('checkinLevel'), gift: chat.get('gift'), + giftPoint: chat.get('point'), luckyBag: chat.get('luckyBag'), pokeInfo: chat.get('pokeInfo'), giftType: chat.get('giftType'), diff --git a/web/ably_chat/src/platforms/BasePlatform.js b/web/ably_chat/src/platforms/BasePlatform.js new file mode 100644 index 0000000..89f2b68 --- /dev/null +++ b/web/ably_chat/src/platforms/BasePlatform.js @@ -0,0 +1,86 @@ +/** + * Platform handler abstract base class + * Defines interfaces all platforms must implement + */ + +import { EventEmitter } from 'events'; + +export class BasePlatform extends EventEmitter { + constructor(platformId, platformName) { + super(); + this.platformId = platformId; + this.platformName = platformName; + this.isConnected = false; + this.messageQueue = []; + this.maxQueueSize = 1000; + } + + /** + * Connect to the platform + * @abstract + */ + async connect(config) { + throw new Error('connect() must be implemented by subclass'); + } + + /** + * Disconnect + * @abstract + */ + async disconnect() { + throw new Error('disconnect() must be implemented by subclass'); + } + + /** + * Process raw message data + * @abstract + */ + processRawMessage(rawData) { + throw new Error('processRawMessage() must be implemented by subclass'); + } + + /** + * Send a message to the platform + * @abstract + */ + async sendMessage(message) { + throw new Error('sendMessage() must be implemented by subclass'); + } + + /** + * Get platform status + */ + getStatus() { + return { + platformId: this.platformId, + platformName: this.platformName, + isConnected: this.isConnected, + queueSize: this.messageQueue.length + }; + } + + /** + * Clear message queue + */ + clearQueue() { + this.messageQueue = []; + } + + /** + * Add message to queue + */ + enqueueMessage(message) { + if (this.messageQueue.length >= this.maxQueueSize) { + this.messageQueue.shift(); // Remove the oldest message + } + this.messageQueue.push(message); + this.emit('message', message); + } + + /** + * Get platform icon path + */ + getPlatformIcon() { + return `/images/${this.platformId}.svg`; + } +} \ No newline at end of file diff --git a/web/ably_chat/src/platforms/twitch/components/TwitchMessage.jsx b/web/ably_chat/src/platforms/twitch/components/TwitchMessage.jsx new file mode 100644 index 0000000..5a7cf45 --- /dev/null +++ b/web/ably_chat/src/platforms/twitch/components/TwitchMessage.jsx @@ -0,0 +1,341 @@ +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { Twitch } from 'lucide-react'; + +/** + * Twitch message UI component + * Converts the unified message format into Twitch-specific UI + */ + +const MessageContainer = styled.div` + padding: 0.5rem; + border-radius: 0.25rem; + transition: background-color 0.2s ease; +`; + +const MessageRow = styled.div` + display: flex; + align-items: flex-start; + gap: 0.5rem; +`; + +const MessageContent = styled.div` + flex: 1; +`; + +const AuthorInfo = styled.div` + display: flex; + align-items: center; + gap: 0.25rem; + margin-bottom: 0.25rem; +`; + +const Badge = styled.span` + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + color: white; + border-radius: 0.125rem; +`; + +const Username = styled.span` + font-weight: 500; + font-size: 0.875rem; +`; + +const MessageText = styled.div` + font-size: 0.875rem; + color: #FFFFFF; +`; + +const SubscriptionContainer = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.25rem; + background-color: ${props => props.variant === 'subscription' ? (props.dark ? 'rgba(88, 28, 135, 0.2)' : '#faf5ff') : (props.dark ? 'rgba(88, 28, 135, 0.3)' : '#f3e8ff')}; +`; + +const SubscriptionContent = styled.div` + flex: 1; +`; + +const SubscriptionText = styled.div` + font-size: 0.875rem; + color: ${props => props.variant === 'subscription' ? (props.dark ? '#c084fc' : '#9333ea') : (props.dark ? '#d8b4fe' : '#7c3aed')}; +`; + +const SubscriptionMeta = styled.div` + font-size: 0.75rem; + color: ${props => props.variant === 'subscription' ? (props.dark ? '#a855f7' : '#a855f7') : (props.dark ? '#c084fc' : '#9333ea')}; +`; + +const SimpleRow = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + color: ${props => props.dark ? '#9ca3af' : '#6b7280'}; + font-size: ${props => props.small ? '0.75rem' : '0.875rem'}; +`; + +const SimpleIcon = styled(Twitch)` + width: ${props => props.small ? '0.75rem' : '1rem'}; + height: ${props => props.small ? '0.75rem' : '1rem'}; + color: ${props => props.color === 'purple' ? '#a855f7' : props.color === 'blue' ? '#3b82f6' : '#a855f7'}; + flex-shrink: 0; +`; + +export const TwitchMessage = memo(({ message }) => { + const getMessageStyle = () => { + switch (message.type) { + case 'comment': + return 'twitch-chat-message'; + case 'subscription': + return 'twitch-subscription-message'; + case 'resub': + return 'twitch-resub-message'; + case 'cheer': + return 'twitch-cheer-message'; + case 'join': + return 'twitch-join-message'; + default: + return 'twitch-default-message'; + } + }; + + const getAuthorBadges = () => { + if (!message.author?.badges) return []; + + return message.author.badges.map(badge => { + let badgeText = ''; + let badgeColor = ''; + + switch (badge.name) { + case 'broadcaster': + badgeText = 'Broadcaster'; + badgeColor = 'bg-red-500'; + break; + case 'moderator': + badgeText = 'Moderator'; + badgeColor = 'bg-green-500'; + break; + case 'vip': + badgeText = 'VIP'; + badgeColor = 'bg-blue-500'; + break; + case 'subscriber': + badgeText = 'Subscriber'; + badgeColor = 'bg-purple-500'; + break; + case 'premium': + badgeText = 'Prime'; + badgeColor = 'bg-blue-400'; + break; + case 'turbo': + badgeText = 'Turbo'; + badgeColor = 'bg-purple-400'; + break; + default: + badgeText = badge.name; + badgeColor = 'bg-gray-500'; + } + + return { + text: badgeText, + color: badgeColor + }; + }); + }; + + const renderContent = () => { + const isDark = document.documentElement.classList.contains('dark'); + + switch (message.type) { + case 'comment': + return ( + + + + + {getAuthorBadges().map((badge, index) => ( + + {badge.text} + + ))} + + {message.author?.displayName || message.author?.name} + + + + {message.content} + + + + ); + + case 'subscription': + return ( + + + + + 🎉 {message.content} + + + + ); + + case 'resub': + return ( + + + + + 🎊 {message.content} + + {message.metadata?.months && ( + + Subscription duration: {message.metadata.months} months + + )} + + + ); + + case 'cheer': + return ( + + + + + 💎 {message.content} + + {message.metadata?.bits && ( + + Bits: {message.metadata.bits} + + )} + + + ); + + case 'join': + return ( + + + {message.content} + + ); + + default: + return ( + + + {message.content} + + ); + } + }; + + return ( + + {renderContent()} + + ); +}; + +/** + * Convert the unified message format to Chat component props + */ +export const convertToChatProps = (message) => { + const baseProps = { + key: message.id, + type: message.type, + platform: 'twitch', + timestamp: message.timestamp, + content: message.content + }; + + switch (message.type) { + case 'comment': + return { + ...baseProps, + type: 'comment', + user: { + name: message.author?.displayName || message.author?.name, + avatar: message.author?.avatar, + color: message.author?.color || '#9146FF', + badges: message.author?.badges || [] + }, + message: message.content, + emotes: message.metadata?.emotes + }; + + case 'subscription': + return { + ...baseProps, + type: 'gift', + user: { + name: message.author?.displayName || message.author?.name + }, + gift: { + name: 'Subscription', + count: 1 + }, + message: message.content + }; + + case 'resub': + return { + ...baseProps, + type: 'gift', + user: { + name: message.author?.displayName || message.author?.name + }, + gift: { + name: 'Resubscription', + count: message.metadata?.months || 1 + }, + message: message.content + }; + + case 'cheer': + return { + ...baseProps, + type: 'gift', + user: { + name: message.author?.displayName || message.author?.name + }, + gift: { + name: 'Bits', + count: message.metadata?.bits || 0 + }, + message: message.content + }; + + case 'join': + return { + ...baseProps, + type: 'join', + user: { + name: message.author?.name + }, + message: message.content + }; + + default: + return { + ...baseProps, + type: 'comment', + user: { + name: message.author?.displayName || message.author?.name || 'Twitch User' + }, + message: message.content + }; + } +}; + +export default TwitchMessage; \ No newline at end of file diff --git a/web/ably_chat/src/platforms/twitch/core/TwitchPlatform.js b/web/ably_chat/src/platforms/twitch/core/TwitchPlatform.js new file mode 100644 index 0000000..ceaadcb --- /dev/null +++ b/web/ably_chat/src/platforms/twitch/core/TwitchPlatform.js @@ -0,0 +1,380 @@ +/** + * Twitch Platform Handler + * Uses TMI.js to process Twitch chat messages + */ + +import { BasePlatform } from '../../BasePlatform'; +import { nanoid } from 'nanoid'; +import { fromJS } from 'immutable'; +import { MsgType_COMMENT, MsgType_JOIN_ROOM, MsgType_NEW_GIFT } from '@/lib/constants'; +// Dev-only mock messages (aligned with 17live pattern) +// import twitchMockChat from '@/../public/mock/twitch_chat_message.json'; +// import twitchMockJoin from '@/../public/mock/twitch_chat_join.json'; +// import twitchMockSub from '@/../public/mock/twitch_chat_subscription.json'; + +export class TwitchPlatform extends BasePlatform { + constructor() { + super('twitch', 'Twitch'); + this.devMocksInjected = false; + // Always attempt to inject mocks at construction time; gating handled in injectDevMocks + try { + this.injectDevMocks(); + } catch (e) { + console.warn('Failed to inject Twitch mock:', e); + } + } + + async connect(config) { + this.isConnected = true; + this.emit('connected', { platform: this.platformId, config: config || {} }); + } + + injectDevMocks() { + if (this.devMocksInjected || process.env.NEXT_PUBLIC_MOCK !== '1') return; + // Use imported mock data to ensure bundler resolves JSON correctly + const mocks = [ + // this.processRawMessage(twitchMockChat), + // this.processRawMessage(twitchMockJoin), + // this.processRawMessage(twitchMockSub), + ].filter(Boolean); + + mocks.forEach((mock) => this.enqueueMessage(mock)); + this.devMocksInjected = true; + } + async disconnect() { + this.isConnected = false; + this.emit('disconnected', { platform: this.platformId }); + } + + // No external event listeners; messages arrive via WebSocket routing + + handleWsMessage({ type, payload }) { + if (type === 'twitch_chat_connected') { + const status = payload?.status; + const connected = status === 'connected'; + this.isConnected = connected; + if (connected) { + this.emit('connected', { platform: this.platformId, config: {} }); + } else { + this.emit('disconnected', { platform: this.platformId }); + } + return; + } + if (type === 'twitch_chat_message') { + const raw = payload?.raw || ''; + const parsed = this.parseWsRaw(raw); + if (parsed) { + const unified = this.processRawMessage({ type: 'chat', ...parsed, timestamp: Date.now() }); + if (unified) this.enqueueMessage(unified); + } + return; + } + } + + parseWsRaw(raw) { + if (!raw || typeof raw !== 'string') return null; + const msgMatch = raw.match(/PRIVMSG\s+#([^\s]+)\s+:(.*)$/); + if (!msgMatch) return null; + const channel = msgMatch[1]; + const message = msgMatch[2]; + let username = ''; + const userMatch = raw.match(/:([^!\s]+)!/); + if (userMatch) username = userMatch[1]; + const tagPartEnd = raw.indexOf(' :'); + const tagStr = tagPartEnd > 0 ? raw.substring(0, tagPartEnd) : ''; + const tags = {}; + if (tagStr.includes('=')) { + tagStr.split(';').forEach(kv => { + const i = kv.indexOf('='); + if (i > 0) { + const k = kv.substring(0, i); + const v = kv.substring(i + 1); + tags[k] = v; + } + }); + } + if (!tags['display-name'] && username) tags['display-name'] = username; + return { channel, tags, message, username }; + } + + handleChatMessage(channel, tags, message, self) { + try { + // Ignore self messages + if (self) return; + + const unifiedMessage = this.processRawMessage({ + type: 'chat', + channel, + tags, + message, + timestamp: Date.now() + }); + + if (unifiedMessage) { + this.enqueueMessage(unifiedMessage); + } + } catch (error) { + console.error('Failed to process Twitch chat message:', error); + this.emit('error', { platform: this.platformId, error }); + } + } + + handleJoinMessage(channel, username) { + try { + const unifiedMessage = this.processRawMessage({ + type: 'join', + channel, + username, + timestamp: Date.now() + }); + + if (unifiedMessage) { + this.enqueueMessage(unifiedMessage); + } + } catch (error) { + console.error('Failed to process Twitch join message:', error); + this.emit('error', { platform: this.platformId, error }); + } + } + + handleSubscription(channel, username, method, message, userstate) { + try { + const unifiedMessage = this.processRawMessage({ + type: 'subscription', + channel, + username, + method, + message, + userstate, + timestamp: Date.now() + }); + + if (unifiedMessage) { + this.enqueueMessage(unifiedMessage); + } + } catch (error) { + console.error('Failed to process Twitch subscription message:', error); + this.emit('error', { platform: this.platformId, error }); + } + } + + handleResub(channel, username, months, message, userstate, methods) { + try { + const unifiedMessage = this.processRawMessage({ + type: 'resub', + channel, + username, + months, + message, + userstate, + methods, + timestamp: Date.now() + }); + + if (unifiedMessage) { + this.enqueueMessage(unifiedMessage); + } + } catch (error) { + console.error('Failed to process Twitch resubscription message:', error); + this.emit('error', { platform: this.platformId, error }); + } + } + + handleCheer(channel, userstate, message) { + try { + const unifiedMessage = this.processRawMessage({ + type: 'cheer', + channel, + userstate, + message, + timestamp: Date.now() + }); + + if (unifiedMessage) { + this.enqueueMessage(unifiedMessage); + } + } catch (error) { + console.error('Failed to process Twitch cheer message:', error); + this.emit('error', { platform: this.platformId, error }); + } + } + + processRawMessage(rawData) { + try { + const { type } = rawData; + + switch (type) { + case 'chat': + return this.processChatMessage(rawData); + case 'join': + return this.processJoinMessage(rawData); + case 'subscription': + return this.processSubscriptionMessage(rawData); + case 'resub': + return this.processResubMessage(rawData); + case 'cheer': + return this.processCheerMessage(rawData); + default: + console.warn('Unknown Twitch message type:', type); + return null; + } + } catch (error) { + console.error('Failed to process Twitch raw message:', error); + return null; + } + } + + // Build Immutable content compatible with the Chat component + prepareIndexedChat(base) { + const type = base?.type; + const id = base?.tags?.id || base?.id || nanoid(); + + if (type === 'chat') { + const { tags, message } = base; + return fromJS({ + id, + messageType: MsgType_COMMENT, + displayName: tags['display-name'] || tags.username, + openID: tags['user-id'], + userID: tags['user-id'], + content: message, + level: 1, + name: { textColor: tags.color || '#9146FF' }, + comment: { textColor: '#FFFFFF' }, + backgroundColor: '', + streamerInfo: null, + }); + } + + if (type === 'join') { + const { username } = base; + return fromJS({ + id, + messageType: MsgType_JOIN_ROOM, + displayName: username, + openID: username, + userID: username, + content: `${username} joined the channel`, + level: 1, + name: { textColor: '#9146FF' }, + comment: { textColor: '#FFFFFF' }, + backgroundColor: '', + streamerInfo: null, + }); + } + + if (type === 'subscription' || type === 'resub' || type === 'cheer') { + const { username, months, userstate, message } = base; + const bits = parseInt(userstate?.bits) || 0; + const giftName = type === 'subscription' ? 'Subscription' : (type === 'resub' ? `Resubscription for ${months} months` : 'Bits'); + const count = type === 'cheer' ? bits : (months || 1); + + return fromJS({ + id, + messageType: MsgType_NEW_GIFT, + displayName: userstate?.['display-name'] || username, + openID: userstate?.['user-id'] || username, + userID: userstate?.['user-id'] || username, + content: message || '', + gift: fromJS({ name: giftName, point: count, icon: '' }), + level: 1, + name: { textColor: '#9146FF' }, + comment: { textColor: '#FFFFFF' }, + backgroundColor: '', + streamerInfo: null, + }); + } + + // Fallback regular comment + return fromJS({ + id, + messageType: MsgType_COMMENT, + displayName: base?.username || 'Twitch user', + openID: base?.username, + userID: base?.username, + content: base?.message || '', + level: 1, + name: { textColor: '#9146FF' }, + comment: { textColor: '#FFFFFF' }, + backgroundColor: '', + streamerInfo: null, + }); + } + + processChatMessage(rawData) { + const immutableContent = this.prepareIndexedChat({ ...rawData, type: 'chat' }); + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content: immutableContent, + }; + } + + processJoinMessage(rawData) { + const immutableContent = this.prepareIndexedChat({ ...rawData, type: 'join' }); + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content: immutableContent, + }; + } + + processSubscriptionMessage(rawData) { + const immutableContent = this.prepareIndexedChat({ ...rawData, type: 'subscription' }); + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content: immutableContent, + }; + } + + processResubMessage(rawData) { + const immutableContent = this.prepareIndexedChat({ ...rawData, type: 'resub' }); + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content: immutableContent, + }; + } + + processCheerMessage(rawData) { + const immutableContent = this.prepareIndexedChat({ ...rawData, type: 'cheer' }); + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp: Date.now(), + content: immutableContent, + }; + } + + parseBadges(badgesString) { + if (!badgesString) return []; + + const badges = []; + const badgePairs = badgesString.split(','); + + badgePairs.forEach(pair => { + const [name, version] = pair.split('/'); + badges.push({ name, version }); + }); + + return badges; + } + + async sendMessage(message) { + if (!this.client || !this.isConnected) { + throw new Error('Twitch not connected'); + } + + try { + await this.client.say(this.channel, message); + } catch (error) { + console.error('Failed to send Twitch message:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/web/ably_chat/src/platforms/twitch/index.js b/web/ably_chat/src/platforms/twitch/index.js new file mode 100644 index 0000000..4ca61de --- /dev/null +++ b/web/ably_chat/src/platforms/twitch/index.js @@ -0,0 +1,3 @@ +export * from './core/TwitchPlatform'; +export { default as TwitchMessage } from './components/TwitchMessage.jsx'; +// auth API removed; platform acts as pure processor via WebSocket diff --git a/web/ably_chat/src/platforms/youtube/components/YouTubeMessage.jsx b/web/ably_chat/src/platforms/youtube/components/YouTubeMessage.jsx new file mode 100644 index 0000000..d21c08d --- /dev/null +++ b/web/ably_chat/src/platforms/youtube/components/YouTubeMessage.jsx @@ -0,0 +1,115 @@ +/** + * YouTube platform message UI component + * Specially handles YouTube platform message display + */ + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import Chat from '@/lib/Chat'; + +const PlatformIcon = styled.img` + width: 16px; + height: 16px; + margin-right: 8px; + border-radius: 50%; +`; + +const MessageWrapper = styled.div` + display: flex; + align-items: flex-start; + margin-bottom: 4px; + position: relative; +`; + +const PlatformBadge = styled.div` + position: absolute; + left: -20px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +`; + +const AuthorBadge = styled.span` + background-color: #ff0000; + color: white; + font-size: 10px; + padding: 2px 4px; + border-radius: 2px; + margin-left: 4px; + font-weight: bold; +`; + +export const YouTubeMessage = memo(({ message, streamerInfo, asideLiveWidth }) => { + const { + id, + type, + content, + author, + metadata, + rawData + } = message; + + // Convert message format to be compatible with existing Chat component + const chatProps = convertToChatProps(message, streamerInfo); + + return ( + + + + +
+ +
+
+ ); +}); + +/** + * Convert unified message format to props required by Chat component + */ +export function convertToChatProps(message, streamerInfo) { + const { id, type, content, author, metadata, rawData } = message; + + const baseProps = { + id, + messageType: 'COMMENT', + content, + asideLiveWidth: undefined, + streamerInfo + }; + + switch (type) { + case 'comment': + return { + ...baseProps, + userID: author.id, + displayName: author.displayName, + nameColor: '#FF0000', + textColor: '#FFFFFF', + backgroundColor: '', + level: 1, // YouTube does not have a level system + isStreamer: author.isChatOwner, + openID: author.id, + // Add YouTube-specific flags + isVerified: author.isVerified, + isModerator: author.isChatModerator, + isSponsor: author.isChatSponsor + }; + + default: + return baseProps; + } +} + +export default YouTubeMessage; \ No newline at end of file diff --git a/web/ably_chat/src/platforms/youtube/core/YouTubePlatform.js b/web/ably_chat/src/platforms/youtube/core/YouTubePlatform.js new file mode 100644 index 0000000..c090fea --- /dev/null +++ b/web/ably_chat/src/platforms/youtube/core/YouTubePlatform.js @@ -0,0 +1,140 @@ +/** + * YouTube platform handler + * Handles YouTube live chat messages + */ + +import { BasePlatform } from '../../BasePlatform'; +import { nanoid } from 'nanoid'; +import { fromJS } from 'immutable'; +import { MsgType_COMMENT, MsgType_JOIN_ROOM } from '@/lib/constants'; +// Dev-only mock messages (aligned with 17live pattern) +// import youtubeMockComment from '@/../public/mock/youtube_chat_message.json'; +// import youtubeMockJoin from '@/../public/mock/youtube_chat_join.json'; + +export class YouTubePlatform extends BasePlatform { + constructor() { + super('youtube', 'YouTube'); + this.devMocksInjected = false; + // Always attempt to inject mocks at construction time; gating handled in injectDevMocks + try { + this.injectDevMocks(); + } catch (e) { + console.warn('YouTube mock injection failed:', e); + } + } + + async connect(config) { + this.isConnected = true; + this.emit('connected', { platform: this.platformId, config: config || {} }); + } + + injectDevMocks() { + if (this.devMocksInjected || process.env.NEXT_PUBLIC_MOCK !== '1') return; + const mocks = [ + // this.processRawMessage(youtubeMockComment), + // { + // id: youtubeMockJoin.id, + // platform: this.platformId, + // timestamp: Date.now(), + // content: fromJS({ + // id: youtubeMockJoin.id, + // messageType: MsgType_JOIN_ROOM, + // displayName: youtubeMockJoin.authorDetails?.displayName || 'YouTube Visitor', + // openID: youtubeMockJoin.authorDetails?.channelId, + // userID: youtubeMockJoin.authorDetails?.channelId, + // content: 'YouTube Visitor joined the live room', + // level: 1, + // name: { textColor: '#FF0000' }, + // comment: { textColor: '#FFFFFF' }, + // backgroundColor: '', + // streamerInfo: null, + // }), + // }, + ].filter(Boolean); + + mocks.forEach((mock) => this.enqueueMessage(mock)); + console.log('youtube', mocks); + this.devMocksInjected = true; + } + async disconnect() { + this.isConnected = false; + this.emit('disconnected', { platform: this.platformId }); + } + + // Polling removed; messages arrive via WebSocket routing + + handleWsMessage({ type, payload }) { + if (type === 'youtube_chat_connected') { + const status = payload?.status; + const connected = status === 'connected'; + this.isConnected = connected; + if (connected) { + this.emit('connected', { platform: this.platformId, config: {} }); + } else { + this.emit('disconnected', { platform: this.platformId }); + } + return; + } + if (type === 'youtube_chat_message') { + const unified = this.processRawMessage(payload); + if (unified) this.enqueueMessage(unified); + return; + } + } + + // Build Immutable content compatible with Chat component + prepareIndexedChat(rawData) { + const { snippet, authorDetails } = rawData || {}; + const id = rawData?.id || nanoid(); + const displayName = authorDetails?.displayName || 'YouTube User'; + const isOwner = !!authorDetails?.isChatOwner; + + return fromJS({ + id, + messageType: MsgType_COMMENT, + displayName, + openID: displayName, + userID: displayName, + content: snippet?.displayMessage || '', + level: 1, + isStreamer: isOwner, + name: { textColor: '#FF0000' }, + comment: { textColor: '#FFFFFF' }, + backgroundColor: '', + streamerInfo: null, + }); + } + + processRawMessage(rawData) { + try { + const { snippet, authorDetails } = rawData; + + if (!snippet || !authorDetails) { + return null; + } + + // Only process chat messages + if (snippet.type !== 'textMessageEvent') { + return null; + } + + const timestamp = new Date(snippet.publishedAt).getTime(); + const immutableContent = this.prepareIndexedChat(rawData); + + return { + id: immutableContent.get('id'), + platform: this.platformId, + timestamp, + content: immutableContent, + }; + } catch (error) { + console.error('Failed to process YouTube message:', error); + return null; + } + } + + async sendMessage(message) { + // YouTube requires OAuth to send messages; not supported here + throw new Error('YouTube platform sending messages requires OAuth; not supported'); + } +} diff --git a/web/ably_chat/src/platforms/youtube/index.js b/web/ably_chat/src/platforms/youtube/index.js new file mode 100644 index 0000000..ca51fbf --- /dev/null +++ b/web/ably_chat/src/platforms/youtube/index.js @@ -0,0 +1,3 @@ +export * from './core/YouTubePlatform'; +export { default as YouTubeMessage } from './components/YouTubeMessage'; +// auth API removed; platform acts as pure processor via WebSocket \ No newline at end of file diff --git a/web/ably_chat/src/services/MessageAggregator.js b/web/ably_chat/src/services/MessageAggregator.js new file mode 100644 index 0000000..0ec15bb --- /dev/null +++ b/web/ably_chat/src/services/MessageAggregator.js @@ -0,0 +1,605 @@ +import { EventEmitter } from 'events'; +import { fromJS } from 'immutable'; +import { OneSevenLivePlatform } from '../platforms/17live/core/OneSevenLivePlatform'; +import { TwitchPlatform } from '../platforms/twitch/core/TwitchPlatform'; +import { sendWSMessage } from './WSSender'; + +/** + * Unified message aggregation manager + * Manages multiple platform message streams with unified processing and dispatch + */ +export class MessageAggregator extends EventEmitter { + constructor() { + super(); + + // Platform instance map + this.platforms = new Map(); + + // Active platform set + this.activePlatforms = new Set(); + + // Message queue (sorted by timestamp) + this.messageQueue = []; + + // Message deduplication set (based on message ID) + this.messageIds = new Set(); + + // Max message queue length + this.maxQueueSize = 1000; + + // Local storage config + this.storageKey = 'obs17live_chat_messages_v1'; + this.maxStored = 1000; + this.history = []; + this.currentRoomId = null; + + // Message processing interval (ms) + this.processInterval = 100; + + // Timer reference + this.processTimer = null; + + // Platform handler mapping + this.platformHandlers = { + '17live': OneSevenLivePlatform, + 'twitch': TwitchPlatform + }; + + this.initialize(); + } + + initialize() { + this.startMessageProcessor(); + this.setupEventListeners(); + this.currentRoomId = this.getCurrentRoomId(); + try { console.log('[MsgAgg] init storageKey', this.storageKey, 'roomId', this.currentRoomId); } catch {} + this.loadFromStorage(); + } + + setupEventListeners() { + // Listen for platform connection state changes + this.on('platform_connected', this.handlePlatformConnected.bind(this)); + this.on('platform_disconnected', this.handlePlatformDisconnected.bind(this)); + this.on('platform_error', this.handlePlatformError.bind(this)); + } + + /** + * Add platform + */ + async addPlatform(platformId, config) { + try { + if (this.platforms.has(platformId)) { + throw new Error(`Platform ${platformId} already exists`); + } + + const PlatformClass = this.platformHandlers[platformId]; + if (!PlatformClass) { + throw new Error(`Unsupported platform: ${platformId}`); + } + + // Create platform instance + const platform = new PlatformClass(); + + // Listen for platform message events + platform.on('message', (message) => { + this.handlePlatformMessage(platformId, message); + }); + + platform.on('connected', (data) => { + this.emit('platform_connected', { platformId, data }); + }); + + platform.on('disconnected', (data) => { + this.emit('platform_disconnected', { platformId, data }); + }); + + platform.on('error', (error) => { + this.emit('platform_error', { platformId, error }); + }); + + // Add to platform map + this.platforms.set(platformId, platform); + + // Auto-connect if config is provided + if (config && Object.keys(config).length > 0) { + await this.connectPlatform(platformId, config); + } + + console.log(`Platform ${platformId} added`); + + } catch (error) { + console.error(`Failed to add platform ${platformId}:`, error); + throw error; + } + } + + /** + * Remove platform + */ + async removePlatform(platformId) { + try { + const platform = this.platforms.get(platformId); + if (!platform) { + throw new Error(`Platform ${platformId} does not exist`); + } + + // Disconnect + if (platform.isConnected) { + await platform.disconnect(); + } + + // Remove event listeners + platform.removeAllListeners(); + + // Delete from map + this.platforms.delete(platformId); + this.activePlatforms.delete(platformId); + + console.log(`Platform ${platformId} removed`); + + } catch (error) { + console.error(`Failed to remove platform ${platformId}:`, error); + throw error; + } + } + + /** + * Connect platform + */ + async connectPlatform(platformId, config) { + try { + const platform = this.platforms.get(platformId); + if (!platform) { + throw new Error(`Platform ${platformId} does not exist`); + } + + if (platform.isConnected) { + console.log(`Platform ${platformId} already connected`); + return; + } + + await platform.connect(config); + this.activePlatforms.add(platformId); + + console.log(`Platform ${platformId} connected`); + + } catch (error) { + console.error(`Failed to connect platform ${platformId}:`, error); + throw error; + } + } + + /** + * Disconnect platform + */ + async disconnectPlatform(platformId) { + try { + const platform = this.platforms.get(platformId); + if (!platform) { + throw new Error(`Platform ${platformId} does not exist`); + } + + if (!platform.isConnected) { + console.log(`Platform ${platformId} already disconnected`); + return; + } + + await platform.disconnect(); + this.activePlatforms.delete(platformId); + + console.log(`Platform ${platformId} disconnected`); + + } catch (error) { + console.error(`Failed to disconnect platform ${platformId}:`, error); + throw error; + } + } + + /** + * Handle platform message + */ + handlePlatformMessage(platformId, message) { + try { + // Message deduplication + if (this.messageIds.has(message.id)) { + return; + } + + // Add platform identifier + const enrichedMessage = { + ...message, + platform: platformId, + aggregatedAt: Date.now() + }; + + // Add to message queue + this.messageQueue.push(enrichedMessage); + this.messageIds.add(message.id); + + // Keep queue length + if (this.messageQueue.length > this.maxQueueSize) { + const removedMessage = this.messageQueue.shift(); + this.messageIds.delete(removedMessage.id); + } + + // Sort by timestamp + this.messageQueue.sort((a, b) => a.timestamp - b.timestamp); + + // Emit message event immediately (single message stream) + this.emit('message', enrichedMessage); + + // Forward to WebSocket centrally (on demand) + try { + const content = enrichedMessage.content; + const type = content && typeof content.get === 'function' ? content.get('messageType') : undefined; + const gift = content && typeof content.get === 'function' ? content.get('gift') : undefined; + + if (gift) { + const playData = { + type: 'play_vff', + vffURL: gift.get('vffURL'), + vffJson: gift.get('vffJson'), + }; + sendWSMessage({ + type, + platform: platformId, + payload: playData, + }); + } + } catch (e) { + console.error('WS forwarding failed:', e); + } + + } catch (error) { + console.error('Failed to process platform message:', error); + this.emit('error', { type: 'message_processing', error, platformId, message }); + } + } + + /** + * Handle platform connected event + */ + handlePlatformConnected({ platformId, data }) { + console.log(`Platform ${platformId} connected successfully:`, data); + this.emit('status_change', { + platformId, + status: 'connected', + timestamp: Date.now() + }); + } + + /** + * Handle platform disconnected event + */ + handlePlatformDisconnected({ platformId, data }) { + console.log(`Platform ${platformId} disconnected:`, data); + this.activePlatforms.delete(platformId); + this.emit('status_change', { + platformId, + status: 'disconnected', + timestamp: Date.now() + }); + } + + /** + * Handle platform error event + */ + handlePlatformError({ platformId, error }) { + console.error(`Platform ${platformId} error:`, error); + this.emit('error', { + type: 'platform_error', + platformId, + error, + timestamp: Date.now() + }); + } + + /** + * Start message processor + */ + startMessageProcessor() { + if (this.processTimer) { + clearInterval(this.processTimer); + } + + this.processTimer = setInterval(() => { + this.processMessageQueue(); + }, this.processInterval); + } + + /** + * Process message queue + */ + processMessageQueue() { + if (this.messageQueue.length === 0) { + return; + } + + const now = Date.now(); + const messagesToProcess = []; + + // Get messages to process (based on timestamp) + while (this.messageQueue.length > 0) { + const message = this.messageQueue[0]; + + // If message timestamp <= now, process it + if (message.timestamp <= now) { + messagesToProcess.push(this.messageQueue.shift()); + } else { + break; + } + } + + // Emit batch of messages and persist history + if (messagesToProcess.length > 0) { + // Append to history (dedup by id) + for (const m of messagesToProcess) { + const idx = this.history.findIndex((x) => x.id === m.id); + if (idx === -1) { + this.history.push(m); + } else { + this.history[idx] = m; + } + } + if (this.history.length > this.maxStored) { + this.history = this.history.slice(-this.maxStored); + } + // Persist + this.saveToStorage(); + // Notify UI + this.emit('messages_batch', messagesToProcess); + } + } + + /** + * Get current roomID from URL + */ + getCurrentRoomId() { + try { + if (typeof window === 'undefined') return null; + const params = new URLSearchParams(window.location.search); + const roomID = params.get('roomID'); + return roomID || null; + } catch { + return null; + } + } + + /** + * Load messages from local storage + */ + loadFromStorage() { + try { + if (typeof window === 'undefined' || !window.localStorage) return; + const raw = window.localStorage.getItem(this.storageKey); + if (!raw) { + try { console.log('[MsgAgg] no storage for key', this.storageKey); } catch {} + return; + } + try { console.log('[MsgAgg] load raw length', raw.length); } catch {} + const parsed = JSON.parse(raw); + const restored = []; + if (Array.isArray(parsed)) { + try { console.log('[MsgAgg] parsed array size', parsed.length); } catch {} + for (const item of parsed) { + const id = item && item.id; + if (!id || this.messageIds.has(id)) continue; + const content = item && item.content; + const unified = { + id, + platform: item.platform, + timestamp: item.timestamp || Date.now(), + content: content && typeof content === 'object' ? fromJS(content) : content, + aggregatedAt: Date.now(), + }; + this.messageIds.add(id); + restored.push(unified); + } + } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.chats)) { + // Require matching roomId when provided + const bundleRoomId = parsed.roomId || null; + try { console.log('[MsgAgg] parsed bundle roomId', bundleRoomId, 'current', this.currentRoomId, 'chats', parsed.chats.length); } catch {} + if (bundleRoomId && this.currentRoomId && bundleRoomId !== this.currentRoomId) { + try { console.log('[MsgAgg] skip bundle due to roomId mismatch'); } catch {} + return; + } + for (const chat of parsed.chats) { + const id = chat && chat.id; + if (!id || this.messageIds.has(id)) continue; + const ts = (chat && (chat.sendTime || chat.timestamp)) || (parsed.timestamp || Date.now()); + const unified = { + id, + platform: chat.platform || '17live', + timestamp: ts, + content: fromJS(chat), + aggregatedAt: Date.now(), + }; + this.messageIds.add(id); + restored.push(unified); + } + } else { + try { console.log('[MsgAgg] parsed unknown format'); } catch {} + return; + } + this.history = restored.slice(-this.maxStored); + this.history.sort((a, b) => a.timestamp - b.timestamp); + try { console.log('[MsgAgg] restored count', this.history.length); } catch {} + if (this.history.length) { + this.emit('messages_batch', this.history); + try { console.log('[MsgAgg] emitted batch', this.history.length); } catch {} + } + } catch (e) { + console.error('Failed to load messages from storage:', e); + } + } + + /** + * Save tail messages to local storage + */ + saveToStorage() { + try { + if (typeof window === 'undefined' || !window.localStorage) return; + let tail = this.history.slice(-this.maxStored); + // Ensure chronological order + tail = tail.sort((a, b) => a.timestamp - b.timestamp); + // Persist all platforms' chats merged under the same roomId (assumed same user) + const chats = tail + .filter((m) => m && m.content) + .map((m) => { + const content = typeof m.content.get === 'function' ? m.content.toJS() : m.content; + return { + ...content, + platform: m.platform + }; + }); + const bundle = { + roomId: this.currentRoomId || '', + timestamp: Date.now(), + chats, + }; + window.localStorage.setItem(this.storageKey, JSON.stringify(bundle)); + } catch (e) { + console.error('Failed to save messages to storage:', e); + } + } + + /** + * Get persisted history (tail up to limit) + */ + getHistory(limit = 1000) { + const n = Math.max(0, Math.min(limit, this.history.length)); + return this.history.slice(this.history.length - n); + } + + /** + * Get all platform statuses + */ + getPlatformsStatus() { + const status = {}; + + this.platforms.forEach((platform, platformId) => { + status[platformId] = { + platformId, + platformName: platform.platformName, + isConnected: platform.isConnected, + isActive: this.activePlatforms.has(platformId), + queueSize: platform.messageQueue ? platform.messageQueue.length : 0 + }; + }); + + return status; + } + + /** + * Get message statistics + */ + getMessageStats() { + const stats = { + total: this.messageQueue.length, + byPlatform: {}, + byType: {}, + timestamp: Date.now() + }; + + // Stats by platform + this.messageQueue.forEach(message => { + const platform = message.platform; + const type = message.type; + + if (!stats.byPlatform[platform]) { + stats.byPlatform[platform] = 0; + } + stats.byPlatform[platform]++; + + if (!stats.byType[type]) { + stats.byType[type] = 0; + } + stats.byType[type]++; + }); + + return stats; + } + + /** + * Clear message queue + */ + clearMessageQueue() { + this.messageQueue = []; + this.messageIds.clear(); + this.emit('queue_cleared', { timestamp: Date.now() }); + } + + /** + * Send message to specified platform + */ + async sendMessage(platformId, message) { + try { + const platform = this.platforms.get(platformId); + if (!platform) { + throw new Error(`Platform ${platformId} does not exist`); + } + + if (!platform.isConnected) { + throw new Error(`Platform ${platformId} not connected`); + } + + await platform.sendMessage(message); + + } catch (error) { + console.error(`Failed to send message to platform ${platformId}:`, error); + throw error; + } + } + + /** + * Get messages from all active platforms + */ + getActiveMessages(limit = 100) { + const activeMessages = this.messageQueue.filter(message => + this.activePlatforms.has(message.platform) + ); + + return activeMessages.slice(-limit); + } + + /** + * Stop aggregator + */ + async stop() { + try { + // Stop message processor + if (this.processTimer) { + clearInterval(this.processTimer); + this.processTimer = null; + } + + // Disconnect all platforms + const disconnectPromises = Array.from(this.platforms.keys()).map(platformId => + this.disconnectPlatform(platformId).catch(error => + console.error(`Failed to disconnect platform ${platformId}:`, error) + ) + ); + + await Promise.all(disconnectPromises); + + // Clear data + this.clearMessageQueue(); + this.platforms.clear(); + this.activePlatforms.clear(); + + // Remove all event listeners + this.removeAllListeners(); + + console.log('Message aggregator stopped'); + + } catch (error) { + console.error('Failed to stop message aggregator:', error); + throw error; + } + } +} + +// Create singleton instance +export const messageAggregator = new MessageAggregator(); + +export default MessageAggregator; diff --git a/web/ably_chat/src/services/WSSender.js b/web/ably_chat/src/services/WSSender.js new file mode 100644 index 0000000..02cbfb8 --- /dev/null +++ b/web/ably_chat/src/services/WSSender.js @@ -0,0 +1,51 @@ +/** + * Common WebSocket send function + * - Can be called from anywhere + * - Ensures connection automatically (idempotent) + * - Unified envelope format for messages + */ +import { wsManager } from './WebSocketManager'; + +/** + * Send to WS server + * params: { type, payload, platform, roomID, userID, source, extra } + */ +export async function sendWSMessage({ + type, + payload = {}, + platform, + roomID, + userID, + source = 'client', + extra = {}, +} = {}) { + if (!type) { + throw new Error('sendWSMessage requires a type'); + } + try { + // Do not auto-connect. Only send when WS is already configured and open. + const status = wsManager.getStatus(); + if (!status.configured || status.status !== 'open') { + console.warn('WS not connected or URL missing, skipping send'); + return false; + } + const envelope = { + source, + platform, + roomID, + userID, + type, + payload, + timestamp: Date.now(), + ...extra, + }; + // Send (if not connected it queues, will send after connect) + wsManager.send(envelope); + return true; + } catch (error) { + console.error('sendWSMessage failed:', error); + return false; + } +} + +export default sendWSMessage; \ No newline at end of file diff --git a/web/ably_chat/src/services/WebSocketManager.js b/web/ably_chat/src/services/WebSocketManager.js new file mode 100644 index 0000000..8448606 --- /dev/null +++ b/web/ably_chat/src/services/WebSocketManager.js @@ -0,0 +1,257 @@ +/** + * Unified WebSocket connection manager + * - Centralizes WebSocket lifecycle, reconnection, and messaging + * - Re-emits events via EventEmitter for subscribers + */ +import { EventEmitter } from 'events'; +import { messageAggregator } from './MessageAggregator'; + +class WebSocketManager extends EventEmitter { + constructor() { + super(); + this.ws = null; + this.url = null; + this.isClosing = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.baseReconnectDelayMs = 1000; + this.messageQueue = []; + } + + /** + * Connect (idempotent) ONLY when `ws` query param exists. + * If the param is missing, no connection attempt is made. + */ + async connect() { + // If already open/connecting, skip + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return this.url; + } + + this.isClosing = false; + this.reconnectAttempts = 0; + + // Resolve URL from `ws` query param only + if (typeof window === 'undefined') { + this.url = null; + return null; + } + + let resolvedUrl = null; + try { + const params = new URLSearchParams(window.location.search); + const raw = params.get('ws'); + if (!raw || !raw.trim()) { + this.url = null; + return null; + } + try { + resolvedUrl = new URL(raw.trim(), window.location.origin).toString(); + } catch { + resolvedUrl = raw.trim(); + } + } catch { + this.url = null; + return null; + } + + this.url = resolvedUrl; + this._open(); + return this.url; + } + + /** Whether a WS URL has been configured (via query param) */ + hasConfiguredURL() { + return !!this.url; + } + + _open() { + // Only attempt open if a URL is configured + if (!this.url || this.isClosing) { + return; + } + try { + this.ws = new WebSocket(this.url); + } catch (err) { + this.emit('error', err); + this._scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.emit('open', { url: this.url }); + // Flush queued messages + while (this.messageQueue.length && this.ws && this.ws.readyState === WebSocket.OPEN) { + const msg = this.messageQueue.shift(); + try { + this.ws.send(msg); + } catch (e) { + // If send fails, re-queue and break to avoid tight loop + this.messageQueue.unshift(msg); + break; + } + } + try { + this.send({ type: 'action', payload: { type: 'register_chatdock' } }); + } catch {} + }; + + this.ws.onmessage = (event) => { + const raw = event.data; + let msg = null; + try { + msg = JSON.parse(raw); + } catch { + // 非 JSON 消息忽略(仅处理统一协议) + return; + } + + const type = msg?.type; + const payload = msg?.payload; + if (!type) return; + + // 路由到平台处理:twitch / youtube / 17live + const routeTo = (platformId, transform) => { + const platform = messageAggregator.platforms?.get(platformId); + if (platform && typeof platform.processRawMessage === 'function') { + const rawPayload = typeof transform === 'function' ? transform(payload) : payload; + const unified = platform.processRawMessage(rawPayload); + if (unified) platform.enqueueMessage(unified); + } + }; + + const parseTwitchPrivmsg = (rawStr) => { + if (!rawStr || typeof rawStr !== 'string') return null; + const s = rawStr.replace(/\r\n?$/, ''); + const idxPriv = s.indexOf('PRIVMSG '); + if (idxPriv < 0) return null; + const idxHash = s.indexOf('#', idxPriv); + if (idxHash < 0) return null; + const idxSpaceAfterChan = s.indexOf(' ', idxHash); + if (idxSpaceAfterChan < 0) return null; + const idxMsg = s.indexOf(' :', idxSpaceAfterChan); + if (idxMsg < 0) return null; + const channel = s.substring(idxHash + 1, idxSpaceAfterChan); + const message = s.substring(idxMsg + 2).trim(); + let username = ''; + const u = s.match(/:([^!\s]+)!/); + if (u) username = u[1]; + return { type: 'chat', channel, username, tags: { 'display-name': username }, message }; + }; + + if (type === 'twitch_chat_connected' || type === 'twitch_chat_message') { + const ensure = () => { + const platform = messageAggregator.platforms?.get('twitch'); + if (!platform) return messageAggregator.addPlatform('twitch', {}).then(() => messageAggregator.platforms.get('twitch')); + return Promise.resolve(platform); + }; + ensure() + .then((platform) => { + if (!platform) return; + if (type === 'twitch_chat_connected') { + if (typeof platform.handleWsMessage === 'function') { + platform.handleWsMessage({ type, payload }); + } + } else if (type === 'twitch_chat_message') { + const parsed = parseTwitchPrivmsg(payload?.raw); + if (parsed) { + routeTo('twitch', () => parsed); + } + } + }) + .catch(() => {}); + } else if (type === 'ably_chat_connected' || type === 'ably_chat_message') { + const ensure = () => { + const platform = messageAggregator.platforms?.get('17live'); + if (!platform) return messageAggregator.addPlatform('17live', {}).then(() => messageAggregator.platforms.get('17live')); + return Promise.resolve(platform); + }; + ensure() + .then((platform) => { + if (!platform) return; + if (typeof platform.handleWsMessage === 'function') { + platform.handleWsMessage({ type, payload }); + } + }) + .catch(() => {}); + } + }; + + this.ws.onerror = (err) => { + this.emit('error', err); + }; + + this.ws.onclose = () => { + this.emit('close', { url: this.url }); + if (!this.isClosing) { + this._scheduleReconnect(); + } + }; + } + + _scheduleReconnect() { + if (this.isClosing || !this.url) return; + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.emit('error', new Error('Max WebSocket reconnect attempts reached')); + return; + } + const delay = this.baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts); + this.reconnectAttempts += 1; + setTimeout(() => { + this._open(); + }, Math.min(delay, 15000)); + } + + /** Send JSON-serializable payload or string */ + send(payload) { + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.send(data); + return true; + } catch (e) { + this.emit('error', e); + return false; + } + } + // Only queue while an actual connection lifecycle is in progress + if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.CLOSING)) { + this.messageQueue.push(data); + } + return false; + } + + /** Close connection and stop reconnection */ + close() { + this.isClosing = true; + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + this.ws = null; + } + } + + getStatus() { + const state = this.ws ? this.ws.readyState : WebSocket.CLOSED; + const map = { + [WebSocket.CONNECTING]: 'connecting', + [WebSocket.OPEN]: 'open', + [WebSocket.CLOSING]: 'closing', + [WebSocket.CLOSED]: 'closed', + }; + return { + url: this.url, + configured: !!this.url, + state, + status: map[state], + reconnectAttempts: this.reconnectAttempts, + queued: this.messageQueue.length, + }; + } +} + +export const wsManager = new WebSocketManager(); +export default wsManager; diff --git a/web/ably_chat/src/styles/GlobalStyle.js b/web/ably_chat/src/styles/GlobalStyle.js new file mode 100644 index 0000000..33d24e0 --- /dev/null +++ b/web/ably_chat/src/styles/GlobalStyle.js @@ -0,0 +1,84 @@ +'use client'; + +import { createGlobalStyle } from 'styled-components'; + +const GlobalStyle = createGlobalStyle` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + tab-size: 4; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + font-feature-settings: normal; + font-variation-settings: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Dark mode styles */ + .dark { + color-scheme: dark; + } + + /* Scrollbar styles */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; + } + + .dark ::-webkit-scrollbar-thumb { + background: #475569; + } + + ::-webkit-scrollbar-thumb:hover { + background: #94a3b8; + } + + .dark ::-webkit-scrollbar-thumb:hover { + background: #64748b; + } + + /* Utility classes */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + .not-sr-only { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; + } +`; + +export default GlobalStyle; diff --git a/web/ably_chat/src/styles/StyledComponentsProvider.js b/web/ably_chat/src/styles/StyledComponentsProvider.js new file mode 100644 index 0000000..db39a32 --- /dev/null +++ b/web/ably_chat/src/styles/StyledComponentsProvider.js @@ -0,0 +1,16 @@ +'use client'; + +import { ThemeProvider } from 'styled-components'; +import GlobalStyle from './GlobalStyle'; +import theme from './theme'; + +export function StyledComponentsProvider({ children }) { + return ( + + + {children} + + ); +} + +export default StyledComponentsProvider; \ No newline at end of file diff --git a/web/ably_chat/src/styles/theme.js b/web/ably_chat/src/styles/theme.js new file mode 100644 index 0000000..9534b5d --- /dev/null +++ b/web/ably_chat/src/styles/theme.js @@ -0,0 +1,149 @@ +// styled-components theme configuration +export const theme = { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + gray: { + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', + }, + purple: { + 50: '#faf5ff', + 100: '#f3e8ff', + 200: '#e9d5ff', + 300: '#d8b4fe', + 400: '#c084fc', + 500: '#a855f7', + 600: '#9333ea', + 700: '#7c3aed', + 800: '#6b21a8', + 900: '#581c87', + }, + blue: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + red: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + }, + yellow: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + }, + green: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + white: '#ffffff', + black: '#000000', + }, + spacing: { + 0: '0px', + 1: '0.25rem', + 2: '0.5rem', + 3: '0.75rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 8: '2rem', + 10: '2.5rem', + 12: '3rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 32: '8rem', + 40: '10rem', + 48: '12rem', + 56: '14rem', + 64: '16rem', + }, + fontSize: { + xs: '0.75rem', + sm: '0.875rem', + base: '1rem', + lg: '1.125rem', + xl: '1.25rem', + '2xl': '1.5rem', + '3xl': '1.875rem', + '4xl': '2.25rem', + '5xl': '3rem', + }, + fontWeight: { + normal: '400', + medium: '500', + semibold: '600', + bold: '700', + }, + borderRadius: { + none: '0px', + sm: '0.125rem', + DEFAULT: '0.25rem', + md: '0.375rem', + lg: '0.5rem', + xl: '0.75rem', + '2xl': '1rem', + '3xl': '1.5rem', + full: '9999px', + }, + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, + darkMode: 'class', +}; + +export default theme; diff --git a/web/ably_chat/src/types/message.js b/web/ably_chat/src/types/message.js new file mode 100644 index 0000000..7adcc6e --- /dev/null +++ b/web/ably_chat/src/types/message.js @@ -0,0 +1,96 @@ +/** + * Unified message type definitions + * Standardizes message formats across all platforms + */ + +export const Platform = { + ALL: 'all', + SEVENTEEN_LIVE: '17live', + TWITCH: 'twitch' +}; + +export const MessageType = { + COMMENT: 'comment', + GIFT: 'gift', + JOIN: 'join', + POKE: 'poke', + AI_COHOST: 'ai_cohost', + LUCKY_BAG: 'lucky_bag' +}; + +/** + * Standardized message format + */ +export class UnifiedMessage { + constructor({ + id, + platform, + type, + content, + author, + timestamp, + rawData, + metadata = {} + }) { + this.id = id; + this.platform = platform; + this.type = type; + this.content = content; + this.author = author; + this.timestamp = timestamp; + this.rawData = rawData; + this.metadata = metadata; + } + + /** + * Get platform icon path + */ + getPlatformIcon() { + const iconMap = { + [Platform.SEVENTEEN_LIVE]: '/images/17live.svg', + [Platform.TWITCH]: '/images/twitch.svg' + }; + return iconMap[this.platform] || '/images/17live.svg'; + } + + /** + * Convert to Immutable object (compatible with existing code) + */ + toImmutable() { + const { fromJS } = require('immutable'); + return fromJS({ + id: this.id, + platform: this.platform, + type: this.type, + content: this.content, + author: this.author, + timestamp: this.timestamp, + rawData: this.rawData, + metadata: this.metadata, + platformIcon: this.getPlatformIcon() + }); + } +} + +/** + * Message author information + */ +export class MessageAuthor { + constructor({ + id, + name, + displayName, + avatar, + level, + badges = [], + isStreamer = false + }) { + this.id = id; + this.name = name; + this.displayName = displayName; + this.avatar = avatar; + this.level = level; + this.badges = badges; + this.isStreamer = isStreamer; + } +} diff --git a/web/vff b/web/vff deleted file mode 160000 index 2eeff3a..0000000 --- a/web/vff +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2eeff3a6a6cdb1dc2e27af18ad66a0dddb686cc8