diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 0000000..007f724 --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,20 @@ +# Copy to .devcontainer/.env (gitignored - matches the repo-wide `.env` +# pattern in .gitignore) and fill in real values before reopening the folder +# in the dev container. Naming mirrors deployment/.env. + +POSTGRES_DB=radiobot +POSTGRES_USER=radiouser +POSTGRES_PASSWORD=devpassword + +# Use a separate dev/test Discord application + bot token here. +# Do NOT reuse the production bot token from deployment/.env. +DISCORD_TOKEN= + +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= + +WEBSITE_URL=http://localhost:5000 +CORS_ALLOWED_ORIGINS=http://localhost:5000 + +JWT_SETTINGS_SECRET=dev-only-secret-change-me +JWT_SETTINGS_INTERNAL_PASSWORD=dev-only-password-change-me diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..bc1b650 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,39 @@ +# Dev-only image. Mirrors deployment/Dockerfile's toolchain (keep native +# dependency versions in sync manually when that file changes) but is not +# multi-stage: source is bind-mounted by docker-compose.yml, not copied in. +# Platform is pinned to linux/amd64 in docker-compose.yml, not here - native +# lib paths below are Debian amd64 multiarch and only resolve correctly there. +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsodium-dev \ + libopus-dev \ + ffmpeg \ + tzdata \ + python3 \ + curl \ + wget \ + unzip \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Bun - Worker.csproj's BuildFrontend target shells out to `bun` on every +# `dotnet build`, so it must be on PATH here (not just in CI/production). +RUN curl -fsSL https://bun.sh/install | bash \ + && ln -s /root/.bun/bin/bun /usr/local/bin/bun + +# libdave (Discord E2EE voice lib) - version pinned to match deployment/Dockerfile. +RUN wget -O /tmp/libdave.zip \ + https://github.com/discord/libdave/releases/download/v1.1.1/cpp/libdave-Linux-X64-boringssl.zip \ + && unzip /tmp/libdave.zip -d /tmp/libdave \ + && cp /tmp/libdave/lib/*.so /usr/lib/ \ + && rm -rf /tmp/libdave /tmp/libdave.zip + +# yt-dlp - same fetch method as deployment/Dockerfile. +ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp /usr/local/bin/yt-dlp +RUN chmod a+rx /usr/local/bin/yt-dlp + +ENV TZ=Asia/Singapore +RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime + +WORKDIR /workspace diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..e1fdaed --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "1.10.0", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e", + "integrity": "sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9cf178d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Discord Music Bot", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "shutdownAction": "stopCompose", + "features": { + // Mounts the host's Docker socket so Testcontainers-based integration + // tests (dotnet test src/Tests/Tests.csproj) can spin up Postgres. + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "forwardPorts": [5000, 5432], + "postCreateCommand": "dotnet restore discord-project.slnx", + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-azuretools.vscode-docker" + ], + "settings": { + "dotnet.defaultSolution": "discord-project.slnx" + } + } + }, + "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..ce9f2d1 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,59 @@ +# Dedicated dev-container stack - intentionally separate from +# deployment/docker-compose.yml (not extended/included), to avoid coupling +# .env resolution across directories. Keep the service shape in sync by hand +# with deployment/docker-compose.yml when config keys change. Env var naming +# (SCREAMING_CASE -> Foo__Bar) mirrors deployment/.env for consistency. +services: + app: + build: + context: . + dockerfile: Dockerfile + platform: linux/amd64 + container_name: discord-bot-devcontainer + command: sleep infinity + volumes: + - ..:/workspace:cached + - nuget-packages:/root/.nuget/packages + - app-node-modules:/workspace/src/UI/App/node_modules + - /var/run/docker.sock:/var/run/docker.sock + ports: + - "5000:5000" + environment: + ASPNETCORE_ENVIRONMENT: Development + DOTNET_ENVIRONMENT: Development + ConnectionStrings__DefaultConnection: Host=postgres;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD};Trust Server Certificate=true; + Discord__Token: ${DISCORD_TOKEN} + SpotifySettings__ClientId: ${SPOTIFY_CLIENT_ID} + SpotifySettings__ClientSecret: ${SPOTIFY_CLIENT_SECRET} + WebsiteSettings__Url: ${WEBSITE_URL} + Cors__AllowedOrigins: "[${CORS_ALLOWED_ORIGINS}]" + JwtSettings__Secret: ${JWT_SETTINGS_SECRET} + JwtSettings__InternalPassword: ${JWT_SETTINGS_INTERNAL_PASSWORD} + JwtSettings__Issuer: ${WEBSITE_URL} + JwtSettings__Audience: ${WEBSITE_URL} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:latest + container_name: discord-bot-devcontainer-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres-data: + nuget-packages: + app-node-modules: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86dc6e0..c4c89d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,23 @@ -name: CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - # Only the test project is built: it covers Domain/Application/Infrastructure. - # The full .slnx is not built here because the Worker frontend build requires Bun. - - name: Run tests - run: dotnet test src/Tests/Tests.csproj -c Release --logger "console;verbosity=normal" +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # Only the test project is built: it covers Domain/Application/Infrastructure. + # The full .slnx is not built here because the Worker frontend build requires Bun. + - name: Run tests + run: dotnet test src/Tests/Tests.csproj -c Release --logger "console;verbosity=normal" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8269ba8..b3d8229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,47 @@ -name: Run Radio Discord Bot -on: - push: - branches: - - master - workflow_dispatch: -jobs: - deploy: - runs-on: [self-hosted, Linux, X64] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Debug - List files - run: | - echo "Root directory contents:" - ls -la - echo "Deployment directory contents:" - ls -la deployment/ - - - name: Stop existing containers - run: | - cd deployment && docker compose -f docker-compose.yml down || true - - - name: Remove old images - run: | - cd deployment && docker rmi radiodiscordbot:latest || true - - - name: Create .env file - run: | - cd deployment - echo "POSTGRES_DB=radiobot" > .env - echo "POSTGRES_USER=radiouser" >> .env - echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env - echo "SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}" >> .env - echo "SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}" >> .env - echo "DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }}" >> .env - echo "WEBSITE_URL=${{ secrets.WEBSITE_URL }}" >> .env - echo "CORS_ALLOWED_ORIGINS=${{ secrets.CORS_ALLOWED_ORIGINS }}" >> .env - echo "JWT_SETTINGS_SECRET=${{ secrets.JWT_SETTINGS_SECRET }}" >> .env - echo "JWT_SETTINGS_INTERNAL_PASSWORD=${{ secrets.JWT_SETTINGS_INTERNAL_PASSWORD }}" >> .env - echo "JWT_SETTINGS_ISSUER=${{ secrets.WEBSITE_URL }}" >> .env - echo "JWT_SETTINGS_AUDIENCE=${{ secrets.WEBSITE_URL }}" >> .env - - - name: Build and run with Docker Compose - run: | - cd deployment && docker compose -f docker-compose.yml build --build-arg YT_DLP_CACHE_BUST=$(date +%Y%m%d) && docker compose -f docker-compose.yml up -d +name: Run Radio Discord Bot +on: + push: + branches: + - master + workflow_dispatch: +jobs: + deploy: + runs-on: [self-hosted, Linux, X64] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Debug - List files + run: | + echo "Root directory contents:" + ls -la + echo "Deployment directory contents:" + ls -la deployment/ + + - name: Stop existing containers + run: | + cd deployment && docker compose -f docker-compose.yml down || true + + - name: Remove old images + run: | + cd deployment && docker rmi radiodiscordbot:latest || true + + - name: Create .env file + run: | + cd deployment + echo "POSTGRES_DB=radiobot" > .env + echo "POSTGRES_USER=radiouser" >> .env + echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env + echo "SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}" >> .env + echo "SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}" >> .env + echo "DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }}" >> .env + echo "WEBSITE_URL=${{ secrets.WEBSITE_URL }}" >> .env + echo "CORS_ALLOWED_ORIGINS=${{ secrets.CORS_ALLOWED_ORIGINS }}" >> .env + echo "JWT_SETTINGS_SECRET=${{ secrets.JWT_SETTINGS_SECRET }}" >> .env + echo "JWT_SETTINGS_INTERNAL_PASSWORD=${{ secrets.JWT_SETTINGS_INTERNAL_PASSWORD }}" >> .env + echo "JWT_SETTINGS_ISSUER=${{ secrets.WEBSITE_URL }}" >> .env + echo "JWT_SETTINGS_AUDIENCE=${{ secrets.WEBSITE_URL }}" >> .env + + - name: Build and run with Docker Compose + run: | + cd deployment && docker compose -f docker-compose.yml build --build-arg YT_DLP_CACHE_BUST=$(date +%Y%m%d) && docker compose -f docker-compose.yml up -d diff --git a/.gitignore b/.gitignore index e6d2806..3fab5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,487 +1,487 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from `dotnet new gitignore` - -# dotenv files -.env - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea/ - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ - -# Mac bundle stuff -*.dmg -*.app - -# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Vim temporary swap files -*.swp - -.claude/* - +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +.claude/* + diff --git a/.vscode/settings.json b/.vscode/settings.json index 99cde57..cbc277d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ -{ - "eslint.useFlatConfig": true, - "eslint.workingDirectories": ["src/UI/App_v2"] -} +{ + "eslint.useFlatConfig": true, + "eslint.workingDirectories": ["src/UI/App"] +} diff --git a/CLAUDE.md b/CLAUDE.md index 29057c8..80c1b7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,123 +1,132 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Discord Music Bot - A .NET 10 application that plays music in Discord voice channels from YouTube, SoundCloud, Spotify (playlist metadata only), and radio streams. Supports multiple servers concurrently: each guild gets its own queue, player loop, voice connection, and FFmpeg process, while statistics and the blacklist are shared globally. Ships with a React dashboard for stats and radio-source management. Designed to run on Linux via Docker; native voice dependencies (libdave, libsodium, libopus) make local Windows runs impractical. - -## Build & Run Commands - -```bash -# Recommended: build and run everything (bot + Postgres) via Docker -docker-compose -f deployment/docker-compose.yml up --build - -# Build the whole solution (uses .slnx format) -dotnet build discord-project.slnx - -# Run the Worker (composition root, hosts Discord bot + web API on :5000) -dotnet run --project src/Worker/Worker.csproj - -# Release configuration -dotnet run --project src/Worker/Worker.csproj -c Release - -# Run tests (unit tests always; integration tests need a local Docker daemon for Testcontainers) -dotnet test src/Tests/Tests.csproj - -# Unit tests only (no Docker required) -dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit" -``` - -Frontend (run from `src/UI/App/`, Bun is the package manager): - -```bash -bun run dev # Vite dev server -bun run build # Type-check + production build (output: src/Worker/wwwroot/) -bun run build:dev # Same, but development mode -bun run lint # ESLint -bun run format # Prettier write -bun run format:check # Prettier check -``` - -Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testcontainers PostgreSQL against the real migrations). The test project references Domain/Application/Infrastructure but deliberately NOT Worker (whose build shells out to Bun for the frontend). - -## Architecture - -### Clean Architecture Layers - -- **Domain** (`src/Domain/`) - Entities (`Song`, `User`, `PlayHistory`, `RadioSource`), event handler interfaces (`IEventHandler`, `IAsyncEventHandler`), and enums. -- **Application** (`src/Application/`) - Business services (`SpotifyService`, `JokeService`, `QuoteService`, `HttpRequestService`), DTOs, config binding helpers, the in-process event dispatcher plus `AddEventing` registration (`Application.Eventing`), and the `GlobalStore` singleton. -- **Infrastructure** (`src/Infrastructure/`) - NetCord commands and interactions, audio pipeline (`GuildPlayerManager`, `GuildPlayer`, `AudioPlayerService`, `FfmpegProcessService`, `MusicQueueService`), `PlayerHandler` (thin guild-scoped Skip/Stop event adapter), YouTube/SoundCloud stream services, EF Core `DiscordBotContext` with compiled models, and radio/user/blacklist/statistics services. -- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL). -- **UI** (`src/UI/`) - - `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Anonymous stats endpoints (`/api/statistics-all`, `/api/statistics-today` for today's top songs, `/api/users`); radio-source CRUD and token validation require authorization. Login checks against `JwtSettings:InternalPassword` (no user password hashing). - - `App/` - React 19 + TypeScript SPA, responsive down to phone widths (breakpoints at 1024/768/480px in `index.css`; `hide-sm`/`hide-md` classes drop table columns on small screens). The index route is an Overview landing page (`pages/Overview.tsx` + `components/RankList.tsx`) with headline stats, top-songs-today, all-time favorites, top listeners, and a latest-activity feed derived from each user's recent songs. The song/user dashboards support search, sortable columns, pagination (`Pagination`/`SortableTh` components, 10 rows per page), an all-time/today filter (songs), relative "last played" timestamps (`utils/time.ts`), and auto-refresh every 60s via TanStack Query `refetchInterval`. Charts adapt to mobile via the `useIsMobile` hook. The API base URL is hardcoded in `services/api.ts`: relative `/api` for builds (the Worker serves the SPA and API from the same origin, so this works for local docker and deployment alike) and `http://localhost:5000/api` only when `import.meta.env.DEV` is true (the Vite dev server). Do NOT reintroduce `VITE_API_BASE_URL` env files: bun auto-loads `.env*` into `process.env`, which outranks `.env.production` in Vite and once leaked the dev URL into a deployed production build. Note that `deployment/docker-compose.override.yml` (VS debug tooling) sets `BUILD_CONFIGURATION: Debug` and a `--wait-for-debugger` entrypoint; it is auto-applied if you run `docker compose` from `deployment/` without `-f`, so always use the explicit `-f deployment/docker-compose.yml` form for real runs. Build output writes into `src/Worker/wwwroot/`, which is served by the Worker as static files with SPA fallback to `index.html`. -- **Worker** (`src/Worker/`) - Composition root. Program.cs builds a `WebApplication` that hosts the NetCord Discord gateway (registered via `AddDiscordGateway`), the ASP.NET Core web API, and the `GuildPlayerManager` hosted service in one process on port 5000. - -### Key Patterns - -**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler` / `IAsyncEventHandler` implementations and registers them as scoped. When adding a new event handler, ensure its assembly is passed to `AddEventing` in `DependencyInjection.cs` (currently `Application.AssemblyMarker` and `Infrastructure.Services.AssemblyMarker`). Only `EventType.Skip` / `EventType.Stop` are dispatched today; both carry a `GuildId` and are handled by `PlayerHandler`, which forwards to `IGuildMusicService`. `EventType.Play` is not dispatched anymore because enqueueing wakes the guild's channel consumer directly. - -**Per-guild players**: `GuildPlayerManager` (a `BackgroundService`, registered as `IGuildMusicService`) owns one `GuildPlayer` per guild, created lazily on the first enqueue for that guild via a component factory (wired in `Worker/DependencyInjection.cs`). Each `GuildPlayer` owns its own `MusicQueueService` (a lock-protected list paired with an unbounded `System.Threading.Channels` signal channel, per the Microsoft queue-service guidance), its own `AudioPlayerService` (voice client) and `FfmpegProcessService` instance, and a consumer loop: it dequeues a `PlayRequest` into the `NowPlaying` slot, awaits `AudioPlayerService.PlayTrackAsync` (retrying failed tracks up to 3 times), and disconnects from that guild's voice channel when its queue runs empty. Skip/Stop cancel the guild's per-track linked `CancellationTokenSource`; commands address a guild through `IGuildMusicService` with `Context.Guild.Id`. None of `MusicQueueService`, `AudioPlayerService`, or `FfmpegProcessService` are DI-registered anymore - the manager constructs them per guild. - -**Keyed services**: Multiple implementations of `IStreamService` and `IRandomService` are registered by name and resolved with `[FromKeyedServices(nameof(...))]`: - -```csharp -services.AddKeyedScoped(nameof(YoutubeService)); -services.AddKeyedScoped(nameof(SoundCloudService)); -services.AddKeyedScoped(nameof(JokeService)); -services.AddKeyedScoped(nameof(QuoteService)); -``` - -**Discord commands** (`src/Infrastructure/Commands/`): - -- `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands -- `MusicActionCommands` - stop, skip, playlist, rewind, statistics -- `AdminCommands` - admin-only actions (blacklist management) -- `MiscCommands` - help, joke, motivate - -Commands and the `NetCordInteraction` component module are wired in `Worker.DependencyInjection.AddWebApplication`. - -**Scoped work from singletons**: `IScopeExecutor` (`ScopeExecutor`) is used by singleton services (like commands calling into scoped `DiscordBotContext`) to open a DI scope on demand. - -### Database - -PostgreSQL + EF Core 10. Context: `Infrastructure/Data/DiscordBotContext.cs`. Uses **compiled models** (`Infrastructure.CompiledModels.DiscordBotContextModel`) for startup performance; if you change the model, regenerate the compiled model in addition to adding a migration. Migrations live in `src/Infrastructure/Data/Migrations/` and are applied automatically at startup by `context.Database.MigrateAsync()` in `Program.cs`. - -### Audio Pipeline - -A play interaction enqueues a `PlayRequest` via `IGuildMusicService` into the guild's `MusicQueueService`; the channel signal wakes that guild's `GuildPlayer` loop, which calls `AudioPlayerService.PlayTrackAsync`. That method joins the voice channel if needed, resolves the stream URL (`YoutubeExplode` / `YoutubeDLSharp` via `yt-dlp` at runtime, or a radio source URL when the selection is a Guid), logs the play via `IStatisticsService` (radio plays are logged with the station name as the title), spawns FFmpeg through `FfmpegProcessService` (`Ffmpeg:Path` config, default `/usr/bin/ffmpeg`), copies FFmpeg stdout into a NetCord `OpusEncodeStream`, then awaits process exit and maps the exit code to a `TrackPlayResult` (`Completed`/`Failed`/`Skipped`/`NotInVoiceChannel`). There are no C# events in this pipeline; user-facing messages flow through `PlayRequest.Callbacks` and failures are retried by the guild's player loop. - -### Native Dependencies (installed in the Docker image) - -- FFmpeg -- libsodium, libopus -- libdave (fetched from the Discord libdave releases zip in the Dockerfile) -- yt-dlp (pulled from the yt-dlp `latest` release at image build time; `YT_DLP_CACHE_BUST` build arg forces a re-download) -- python3 (required by yt-dlp) - -## Configuration - -.NET config keys (colon separators become double-underscore in env vars, per `deployment/docker-compose.yml`): - -- `Discord:Token` -- `SpotifySettings:ClientId` / `SpotifySettings:ClientSecret` -- `ConnectionStrings:DefaultConnection` -- `JwtSettings:Secret` / `Issuer` / `Audience` / `InternalPassword` -- `WebsiteSettings:Url` -- `Cors:AllowedOrigins` (JSON array in env var form: `[origin1,origin2]`) -- `Ffmpeg:Path` (optional, defaults to `/usr/bin/ffmpeg`) - -For local Docker runs, populate `deployment/.env` (see `.github/workflows/release.yml` for the exact key list). - -## Toolchain - -- .NET SDK: `global.json` pins 9.0.3 with `rollForward: latestMajor`, so SDK 10+ satisfies it. All projects target `net10.0` via `src/Directory.Build.props` (nullable + implicit usings enabled). -- Central package management: all NuGet versions live in `src/Directory.Packages.props`. -- Frontend: React 19, Vite 8, TanStack Router + Query, Recharts, Sonner. Uses `babel-plugin-react-compiler` via the Vite React plugin. - -## Deployment - -`.github/workflows/release.yml` deploys on push to `master` via a self-hosted Linux runner: tears down the running compose stack, writes `deployment/.env` from GitHub secrets, then rebuilds and restarts with `docker compose up -d`. The build passes `YT_DLP_CACHE_BUST=$(date +%Y%m%d)` so yt-dlp refreshes daily. - -`.github/workflows/ci.yml` runs `dotnet test src/Tests/Tests.csproj` on GitHub-hosted Ubuntu for pushes and PRs (Docker is preinstalled there, so the Testcontainers integration tests run too). It intentionally does not build the full `.slnx` because the Worker frontend build requires Bun. +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Discord Music Bot - A .NET 10 application that plays music in Discord voice channels from YouTube, SoundCloud, Spotify (playlist metadata only), and radio streams. Supports multiple servers concurrently: each guild gets its own queue, player loop, voice connection, and FFmpeg process, while statistics and the blacklist are shared globally. Ships with a React dashboard for stats and radio-source management. Designed to run on Linux via Docker; native voice dependencies (libdave, libsodium, libopus) make local Windows runs impractical. + +## Build & Run Commands + +```bash +# Recommended: build and run everything (bot + Postgres) via Docker +docker-compose -f deployment/docker-compose.yml up --build + +# Build the whole solution (uses .slnx format) +dotnet build discord-project.slnx + +# Run the Worker (composition root, hosts Discord bot + web API on :5000) +dotnet run --project src/Worker/Worker.csproj + +# Release configuration +dotnet run --project src/Worker/Worker.csproj -c Release + +# Run tests (unit tests always; integration tests need a local Docker daemon for Testcontainers) +dotnet test src/Tests/Tests.csproj + +# Unit tests only (no Docker required) +dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit" +``` + +Frontend (run from `src/UI/App/`, Bun is the package manager): + +```bash +bun run dev # Vite dev server +bun run build # Type-check + production build (output: src/Worker/wwwroot/) +bun run build:dev # Same, but development mode +bun run lint # ESLint +bun run format # Prettier write +bun run format:check # Prettier check +``` + +Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testcontainers PostgreSQL against the real migrations). The test project references Domain/Application/Infrastructure but deliberately NOT Worker (whose build shells out to Bun for the frontend). + +## Architecture + +### Clean Architecture Layers + +- **Domain** (`src/Domain/`) - Entities (`Song`, `User`, `PlayHistory`, `RadioSource`), event handler interfaces (`IEventHandler`, `IAsyncEventHandler`), and enums. +- **Application** (`src/Application/`) - Business services (`SpotifyService`, `JokeService`, `QuoteService`, `HttpRequestService`), DTOs, config binding helpers, the in-process event dispatcher plus `AddEventing` registration (`Application.Eventing`), and the `GlobalStore` singleton. +- **Infrastructure** (`src/Infrastructure/`) - NetCord commands and interactions, audio pipeline (`GuildPlayerManager`, `GuildPlayer`, `AudioPlayerService`, `FfmpegProcessService`, `MusicQueueService`), `PlayerHandler` (thin guild-scoped Skip/Stop event adapter), YouTube/SoundCloud stream services, EF Core `DiscordBotContext` with compiled models, and radio/user/blacklist/statistics services. +- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL). +- **UI** (`src/UI/`) + - `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Anonymous stats endpoints (`/api/statistics-all`, `/api/statistics-today` for today's top songs, `/api/users`); radio-source CRUD and token validation require authorization. Login checks against `JwtSettings:InternalPassword` (no user password hashing). + - `App/` - React 19 + TypeScript SPA, responsive down to phone widths (breakpoints at 1024/768/480px in `index.css`; `hide-sm`/`hide-md` classes drop table columns on small screens). The index route is an Overview landing page (`pages/Overview.tsx` + `components/RankList.tsx`) with headline stats, top-songs-today, all-time favorites, top listeners, and a latest-activity feed derived from each user's recent songs. The song/user dashboards support search, sortable columns, pagination (`Pagination`/`SortableTh` components, 10 rows per page), an all-time/today filter (songs), relative "last played" timestamps (`utils/time.ts`), and auto-refresh every 60s via TanStack Query `refetchInterval`. Charts adapt to mobile via the `useIsMobile` hook. The API base URL is hardcoded in `services/api.ts`: relative `/api` for builds (the Worker serves the SPA and API from the same origin, so this works for local docker and deployment alike) and `http://localhost:5000/api` only when `import.meta.env.DEV` is true (the Vite dev server). Do NOT reintroduce `VITE_API_BASE_URL` env files: bun auto-loads `.env*` into `process.env`, which outranks `.env.production` in Vite and once leaked the dev URL into a deployed production build. Note that `deployment/docker-compose.override.yml` (VS debug tooling) sets `BUILD_CONFIGURATION: Debug` and a `--wait-for-debugger` entrypoint; it is auto-applied if you run `docker compose` from `deployment/` without `-f`, so always use the explicit `-f deployment/docker-compose.yml` form for real runs. Build output writes into `src/Worker/wwwroot/`, which is served by the Worker as static files with SPA fallback to `index.html`. +- **Worker** (`src/Worker/`) - Composition root. Program.cs builds a `WebApplication` that hosts the NetCord Discord gateway (registered via `AddDiscordGateway`), the ASP.NET Core web API, and the `GuildPlayerManager` hosted service in one process on port 5000. + +### Key Patterns + +**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler` / `IAsyncEventHandler` implementations and registers them as scoped. When adding a new event handler, ensure its assembly is passed to `AddEventing` in `DependencyInjection.cs` (currently `Application.AssemblyMarker` and `Infrastructure.Services.AssemblyMarker`). Only `EventType.Skip` / `EventType.Stop` are dispatched today; both carry a `GuildId` and are handled by `PlayerHandler`, which forwards to `IGuildMusicService`. `EventType.Play` is not dispatched anymore because enqueueing wakes the guild's channel consumer directly. + +**Per-guild players**: `GuildPlayerManager` (a `BackgroundService`, registered as `IGuildMusicService`) owns one `GuildPlayer` per guild, created lazily on the first enqueue for that guild via a component factory (wired in `Worker/DependencyInjection.cs`). Each `GuildPlayer` owns its own `MusicQueueService` (a lock-protected list paired with an unbounded `System.Threading.Channels` signal channel, per the Microsoft queue-service guidance), its own `AudioPlayerService` (voice client) and `FfmpegProcessService` instance, and a consumer loop: it dequeues a `PlayRequest` into the `NowPlaying` slot, awaits `AudioPlayerService.PlayTrackAsync` (retrying failed tracks up to 3 times), and disconnects from that guild's voice channel when its queue runs empty. Skip/Stop cancel the guild's per-track linked `CancellationTokenSource`; commands address a guild through `IGuildMusicService` with `Context.Guild.Id`. None of `MusicQueueService`, `AudioPlayerService`, or `FfmpegProcessService` are DI-registered anymore - the manager constructs them per guild. + +**Keyed services**: Multiple implementations of `IStreamService` and `IRandomService` are registered by name and resolved with `[FromKeyedServices(nameof(...))]`: + +```csharp +services.AddKeyedScoped(nameof(YoutubeService)); +services.AddKeyedScoped(nameof(SoundCloudService)); +services.AddKeyedScoped(nameof(JokeService)); +services.AddKeyedScoped(nameof(QuoteService)); +``` + +**Discord commands** (`src/Infrastructure/Commands/`): + +- `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands +- `MusicActionCommands` - stop, skip, playlist, rewind, statistics +- `AdminCommands` - admin-only actions (blacklist management) +- `MiscCommands` - help, joke, motivate + +Commands and the `NetCordInteraction` component module are wired in `Worker.DependencyInjection.AddWebApplication`. + +**Scoped work from singletons**: `IScopeExecutor` (`ScopeExecutor`) is used by singleton services (like commands calling into scoped `DiscordBotContext`) to open a DI scope on demand. + +### Database + +PostgreSQL + EF Core 10. Context: `Infrastructure/Data/DiscordBotContext.cs`. Uses **compiled models** (`Infrastructure.CompiledModels.DiscordBotContextModel`) for startup performance; if you change the model, regenerate the compiled model in addition to adding a migration. Migrations live in `src/Infrastructure/Data/Migrations/` and are applied automatically at startup by `context.Database.MigrateAsync()` in `Program.cs`. + +### Audio Pipeline + +A play interaction enqueues a `PlayRequest` via `IGuildMusicService` into the guild's `MusicQueueService`; the channel signal wakes that guild's `GuildPlayer` loop, which calls `AudioPlayerService.PlayTrackAsync`. That method joins the voice channel if needed, resolves the stream URL (`YoutubeExplode` / `YoutubeDLSharp` via `yt-dlp` at runtime, or a radio source URL when the selection is a Guid), logs the play via `IStatisticsService` (radio plays are logged with the station name as the title), spawns FFmpeg through `FfmpegProcessService` (`Ffmpeg:Path` config, default `/usr/bin/ffmpeg`), copies FFmpeg stdout into a NetCord `OpusEncodeStream`, then awaits process exit and maps the exit code to a `TrackPlayResult` (`Completed`/`Failed`/`Skipped`/`NotInVoiceChannel`). There are no C# events in this pipeline; user-facing messages flow through `PlayRequest.Callbacks` and failures are retried by the guild's player loop. + +### Native Dependencies (installed in the Docker image) + +- FFmpeg +- libsodium, libopus +- libdave (fetched from the Discord libdave releases zip in the Dockerfile) +- yt-dlp (pulled from the yt-dlp `latest` release at image build time; `YT_DLP_CACHE_BUST` build arg forces a re-download) +- python3 (required by yt-dlp) + +## Configuration + +.NET config keys (colon separators become double-underscore in env vars, per `deployment/docker-compose.yml`): + +- `Discord:Token` +- `SpotifySettings:ClientId` / `SpotifySettings:ClientSecret` +- `ConnectionStrings:DefaultConnection` +- `JwtSettings:Secret` / `Issuer` / `Audience` / `InternalPassword` +- `WebsiteSettings:Url` +- `Cors:AllowedOrigins` (JSON array in env var form: `[origin1,origin2]`) +- `Ffmpeg:Path` (optional, defaults to `/usr/bin/ffmpeg`) + +For local Docker runs, populate `deployment/.env` (see `.github/workflows/release.yml` for the exact key list). + +## Dev Container + +`.devcontainer/` provides a full-stack VS Code devcontainer, separate from `deployment/` and not used in production: a single-stage `linux/amd64` image based on `mcr.microsoft.com/dotnet/sdk:10.0` carrying the same native deps as `deployment/Dockerfile` (bun, libsodium-dev, libopus-dev, libdave, ffmpeg, yt-dlp, python3), composed with a `postgres` service. The `docker-outside-of-docker` devcontainer feature plus a bind-mounted `/var/run/docker.sock` give the container access to the host's Docker daemon for Testcontainers. + +- Copy `.devcontainer/.env.example` to `.devcontainer/.env` and set a dev/test Discord bot token (do not reuse the production token) before reopening in container. +- `dotnet build discord-project.slnx`, `dotnet run --project src/Worker/Worker.csproj`, and `dotnet test src/Tests/Tests.csproj` (including Testcontainers integration tests) all work inside the container, unlike CI which skips the full `.slnx` build. +- Native dependency versions (libdave, yt-dlp, apt packages) are duplicated between `deployment/Dockerfile` and `.devcontainer/Dockerfile` - bump both together. +- Pinned to `linux/amd64`: native lib paths are Debian amd64 multiarch (`/usr/lib/x86_64-linux-gnu/...`), hardcoded in `Worker.csproj`. On arm64 hosts (e.g. Apple Silicon), Docker Desktop must emulate via Rosetta/QEMU or the paths silently resolve to nothing and the audio pipeline breaks at runtime. + +## Toolchain + +- .NET SDK: `global.json` pins 9.0.3 with `rollForward: latestMajor`, so SDK 10+ satisfies it. All projects target `net10.0` via `src/Directory.Build.props` (nullable + implicit usings enabled). +- Central package management: all NuGet versions live in `src/Directory.Packages.props`. +- Frontend: React 19, Vite 8, TanStack Router + Query, Recharts, Sonner. Uses `babel-plugin-react-compiler` via the Vite React plugin. + +## Deployment + +`.github/workflows/release.yml` deploys on push to `master` via a self-hosted Linux runner: tears down the running compose stack, writes `deployment/.env` from GitHub secrets, then rebuilds and restarts with `docker compose up -d`. The build passes `YT_DLP_CACHE_BUST=$(date +%Y%m%d)` so yt-dlp refreshes daily. + +`.github/workflows/ci.yml` runs `dotnet test src/Tests/Tests.csproj` on GitHub-hosted Ubuntu for pushes and PRs (Docker is preinstalled there, so the Testcontainers integration tests run too). It intentionally does not build the full `.slnx` because the Worker frontend build requires Bun. diff --git a/README.md b/README.md index 2bfb52b..7297c28 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,128 @@ -# Discord Music Bot - -A .NET 10 Discord bot for music playback, supporting YouTube, SoundCloud, Spotify (playlist metadata), and radio streams. Supports multiple servers concurrently - each server gets its own queue, player, and voice connection. Includes a React-based web dashboard for statistics and radio-source management. - -**Note:** This application is designed to run on Linux via Docker. A Dockerfile and docker-compose setup are provided. - -## Installation - -1. Create a Discord bot and add it to your server. Follow the [Discord Developer docs](https://discord.com/developers/docs/intro). -2. Set up the necessary bot permissions. See [Permissions](https://discord.com/developers/docs/topics/permissions). -3. Obtain a Discord bot token. See [OAuth2 Bots](https://discord.com/developers/docs/topics/oauth2#bots). -4. Clone this repository. -5. Configure `deployment/.env` with your values (Discord token, Spotify credentials, database, JWT, etc.). -6. Build and run with Docker: - ``` - docker-compose -f deployment/docker-compose.yml up --build - ``` - -## Development - -```bash -# Build the whole solution (requires Bun for the frontend build) -dotnet build discord-project.slnx - -# Run all tests (integration tests need a local Docker daemon for Testcontainers) -dotnet test src/Tests/Tests.csproj - -# Unit tests only (no Docker required) -dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit" -``` - -Tests run automatically in CI (`.github/workflows/ci.yml`) on pushes and pull requests to `master`; pushes to `master` are deployed via `.github/workflows/release.yml`. - -## Architecture - -One bot process serves many servers. Commands address a guild through `IGuildMusicService`; `GuildPlayerManager` lazily creates one `GuildPlayer` per guild, each with its own queue, consumer loop, voice client, and FFmpeg process, so playback in one server never affects another. Statistics and the blacklist are shared across all servers. - -```mermaid -flowchart TD - UA["User in Server A"] -->|"/play /skip /stop"| CMD - UB["User in Server B"] -->|"/play /skip /stop"| CMD - - subgraph Worker["Worker process"] - CMD["Slash commands + NetCordInteraction"] - CMD -->|"Enqueue(guildId, PlayRequest)"| MGR - CMD -->|"EventType.Skip / Stop (GuildId)"| PH["EventDispatcher -> PlayerHandler"] - PH -->|"Skip(guildId) / Stop(guildId)"| MGR - MGR["GuildPlayerManager
(IGuildMusicService)
one GuildPlayer per guild"] - - subgraph PA["GuildPlayer - Server A"] - QA["MusicQueueService
(list + channel signal)"] - LA["Consumer loop
(retry x3, per-track cancellation)"] - AA["AudioPlayerService
(voice client A)"] - FA["FfmpegProcessService
(ffmpeg process A)"] - QA -->|"signal wakes"| LA - LA -->|"PlayTrackAsync"| AA - AA -->|"spawn"| FA - end - - subgraph PB["GuildPlayer - Server B"] - QB["MusicQueueService"] - LB["Consumer loop"] - AB["AudioPlayerService
(voice client B)"] - FB["FfmpegProcessService"] - QB -->|"signal wakes"| LB - LB -->|"PlayTrackAsync"| AB - AB -->|"spawn"| FB - end - - MGR -->|"guild A ops"| QA - MGR -->|"guild B ops"| QB - - SS["Stream resolvers
yt-dlp / YoutubeExplode /
SoundCloud / radio URL"] - DB[("PostgreSQL
stats + blacklist
shared by all servers")] - AA -.->|"resolve stream URL"| SS - AB -.-> SS - AA -.->|"log play"| DB - AB -.-> DB - end - - FA -->|"PCM to Opus stream"| VA(("Voice channel
Server A")) - FB -->|"PCM to Opus stream"| VB(("Voice channel
Server B")) -``` - -## Technologies Used - -### Backend -- [.NET 10](https://dotnet.microsoft.com) / ASP.NET Core -- [NetCord](https://github.com/NetCordDev/NetCord) - Discord bot framework -- [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) + [PostgreSQL](https://www.postgresql.org) -- [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) / [YoutubeDLSharp](https://github.com/Bluegrams/YoutubeDLSharp) + [yt-dlp](https://github.com/yt-dlp/yt-dlp) -- [SoundCloudExplode](https://github.com/jerry08/SoundCloudExplode) -- [Spotify Web API](https://developer.spotify.com/documentation/web-api) - playlist metadata -- [FFmpeg](https://ffmpeg.org) - audio processing -- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encoding and encryption -- [xUnit](https://xunit.net) + [NSubstitute](https://nsubstitute.github.io) + [Testcontainers](https://dotnet.testcontainers.org) - testing - -### Frontend -- [React 19](https://react.dev) with TypeScript -- [TanStack Router](https://tanstack.com/router) / [TanStack Query](https://tanstack.com/query) -- [Recharts](https://recharts.org) -- [Vite](https://vite.dev) + [Bun](https://bun.sh) +# Discord Music Bot + +A .NET 10 Discord bot for music playback, supporting YouTube, SoundCloud, Spotify (playlist metadata), and radio streams. Supports multiple servers concurrently - each server gets its own queue, player, and voice connection. Includes a React-based web dashboard for statistics and radio-source management. + +**Note:** This application is designed to run on Linux via Docker. A Dockerfile and docker-compose setup are provided. + +## Installation + +1. Create a Discord bot and add it to your server. Follow the [Discord Developer docs](https://discord.com/developers/docs/intro). +2. Set up the necessary bot permissions. See [Permissions](https://discord.com/developers/docs/topics/permissions). +3. Obtain a Discord bot token. See [OAuth2 Bots](https://discord.com/developers/docs/topics/oauth2#bots). +4. Clone this repository. +5. Configure `deployment/.env` with your values (Discord token, Spotify credentials, database, JWT, etc.). +6. Build and run with Docker: + ``` + docker-compose -f deployment/docker-compose.yml up --build + ``` + +## Development + +```bash +# Build the whole solution (requires Bun for the frontend build) +dotnet build discord-project.slnx + +# Run the Worker (Discord bot + web API on :5000) +dotnet run --project src/Worker/Worker.csproj + +# Run all tests (integration tests need a local Docker daemon for Testcontainers) +dotnet test src/Tests/Tests.csproj + +# Unit tests only (no Docker required) +dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit" +``` + +Tests run automatically in CI (`.github/workflows/ci.yml`) on pushes and pull requests to `master`; pushes to `master` are deployed via `.github/workflows/release.yml`. + +### Frontend + +Run from `src/UI/App/` (Bun is the package manager): + +```bash +bun run dev # Vite dev server +bun run build # Type-check + production build (output: src/Worker/wwwroot/) +bun run lint # ESLint +bun run format # Prettier write +``` + +### Dev Container + +`.devcontainer/` provides a full-stack VS Code Dev Container (separate from `deployment/`, not used in production) with the SDK, Bun, and all native audio dependencies (libsodium, libopus, libdave, ffmpeg, yt-dlp) preinstalled, plus its own Postgres service. + +1. `cp .devcontainer/.env.example .devcontainer/.env` and fill in a dev/test Discord bot token (do not reuse your production token). +2. Reopen the folder in the container (VS Code: "Dev Containers: Reopen in Container"). +3. Inside the container, `dotnet build discord-project.slnx`, `dotnet run --project src/Worker/Worker.csproj`, and `dotnet test src/Tests/Tests.csproj` (including Testcontainers integration tests, via the bind-mounted Docker socket) all work out of the box. + +Pinned to `linux/amd64` — on Apple Silicon/arm64 hosts, Docker Desktop must emulate via Rosetta/QEMU or the native library paths resolve to nothing at runtime. + +> If you ever change `POSTGRES_USER`/`PASSWORD`/`DB` in `.devcontainer/.env` or `deployment/.env` after Postgres has already started once, the existing volume keeps the old credentials — Postgres only applies those env vars on first init of an empty data directory. Fix with `docker-compose down -v` on the relevant stack to force a clean re-init. + +## Architecture + +One bot process serves many servers. Commands address a guild through `IGuildMusicService`; `GuildPlayerManager` lazily creates one `GuildPlayer` per guild, each with its own queue, consumer loop, voice client, and FFmpeg process, so playback in one server never affects another. Statistics and the blacklist are shared across all servers. + +```mermaid +flowchart TD + UA["User in Server A"] -->|"/play /skip /stop"| CMD + UB["User in Server B"] -->|"/play /skip /stop"| CMD + + subgraph Worker["Worker process"] + CMD["Slash commands + NetCordInteraction"] + CMD -->|"Enqueue(guildId, PlayRequest)"| MGR + CMD -->|"EventType.Skip / Stop (GuildId)"| PH["EventDispatcher -> PlayerHandler"] + PH -->|"Skip(guildId) / Stop(guildId)"| MGR + MGR["GuildPlayerManager
(IGuildMusicService)
one GuildPlayer per guild"] + + subgraph PA["GuildPlayer - Server A"] + QA["MusicQueueService
(list + channel signal)"] + LA["Consumer loop
(retry x3, per-track cancellation)"] + AA["AudioPlayerService
(voice client A)"] + FA["FfmpegProcessService
(ffmpeg process A)"] + QA -->|"signal wakes"| LA + LA -->|"PlayTrackAsync"| AA + AA -->|"spawn"| FA + end + + subgraph PB["GuildPlayer - Server B"] + QB["MusicQueueService"] + LB["Consumer loop"] + AB["AudioPlayerService
(voice client B)"] + FB["FfmpegProcessService"] + QB -->|"signal wakes"| LB + LB -->|"PlayTrackAsync"| AB + AB -->|"spawn"| FB + end + + MGR -->|"guild A ops"| QA + MGR -->|"guild B ops"| QB + + SS["Stream resolvers
yt-dlp / YoutubeExplode /
SoundCloud / radio URL"] + DB[("PostgreSQL
stats + blacklist
shared by all servers")] + AA -.->|"resolve stream URL"| SS + AB -.-> SS + AA -.->|"log play"| DB + AB -.-> DB + end + + FA -->|"PCM to Opus stream"| VA(("Voice channel
Server A")) + FB -->|"PCM to Opus stream"| VB(("Voice channel
Server B")) +``` + +## Technologies Used + +### Backend +- [.NET 10](https://dotnet.microsoft.com) / ASP.NET Core +- [NetCord](https://github.com/NetCordDev/NetCord) - Discord bot framework +- [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) + [PostgreSQL](https://www.postgresql.org) +- [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) / [YoutubeDLSharp](https://github.com/Bluegrams/YoutubeDLSharp) + [yt-dlp](https://github.com/yt-dlp/yt-dlp) +- [SoundCloudExplode](https://github.com/jerry08/SoundCloudExplode) +- [Spotify Web API](https://developer.spotify.com/documentation/web-api) - playlist metadata +- [FFmpeg](https://ffmpeg.org) - audio processing +- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encoding and encryption +- [xUnit](https://xunit.net) + [NSubstitute](https://nsubstitute.github.io) + [Testcontainers](https://dotnet.testcontainers.org) - testing + +### Frontend +- [React 19](https://react.dev) with TypeScript +- [TanStack Router](https://tanstack.com/router) / [TanStack Query](https://tanstack.com/query) +- [Recharts](https://recharts.org) +- [Vite](https://vite.dev) + [Bun](https://bun.sh) diff --git a/deployment/.dockerignore b/deployment/.dockerignore index 61bfaa0..514a457 100644 --- a/deployment/.dockerignore +++ b/deployment/.dockerignore @@ -1,32 +1,32 @@ -**/.classpath -**/.dockerignore -**/.env -!**/.env.production -!**/.env.development -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md -!**/.gitignore -!.git/HEAD -!.git/config -!.git/packed-refs +**/.classpath +**/.dockerignore +**/.env +!**/.env.production +!**/.env.development +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs !.git/refs/heads/** \ No newline at end of file diff --git a/deployment/Dockerfile b/deployment/Dockerfile index e6c7b3f..490c288 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -1,80 +1,80 @@ -# Keep your original structure but optimize base image -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS lib-build - -WORKDIR /deps - -RUN apt update && apt install -y --no-install-recommends \ - curl \ - libsodium-dev \ - libopus-dev \ - wget \ - unzip \ - && curl -fsSL https://bun.sh/install | bash \ - && ln -s /root/.bun/bin/bun /usr/local/bin/bun \ - && rm -rf /var/lib/apt/lists/* - -RUN wget -O /tmp/libdave.zip \ - https://github.com/discord/libdave/releases/download/v1.1.1/cpp/libdave-Linux-X64-boringssl.zip && \ - unzip /tmp/libdave.zip -d /tmp/libdave && \ - cp /tmp/libdave/lib/*.so /usr/lib/ && \ - rm -rf /tmp/libdave /tmp/libdave.zip - -FROM lib-build AS build - -ARG BUILD_CONFIGURATION=Release -ARG PROJECT_PATH=src/Worker/Worker.csproj - -# The frontend calls the API via a relative /api path (see src/UI/App/.env.production), -# so the same image works locally and in deployment without a baked-in URL. - -WORKDIR /app - -COPY . . - -RUN dotnet restore "$PROJECT_PATH" - -RUN dotnet build "$PROJECT_PATH" \ - -c $BUILD_CONFIGURATION \ - -o /app/build \ - --os linux \ - --p:DebugSymbols=true \ - --p:DebugType=portable - -FROM build AS publish - -ARG BUILD_CONFIGURATION=Release -WORKDIR /app - -RUN dotnet publish "$PROJECT_PATH" \ - -c $BUILD_CONFIGURATION \ - -o /app/publish \ - --os linux \ - --self-contained false \ - --no-restore - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final - -RUN apt update && apt install -y --no-install-recommends \ - ffmpeg \ - tzdata \ - python3 \ - && rm -rf /var/lib/apt/lists/* \ - && ln -sf /usr/share/zoneinfo/Asia/Singapore /etc/localtime - -# Install latest yt-dlp directly from GitHub releases -ARG YT_DLP_CACHE_BUST=latest -ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp /usr/local/bin/yt-dlp -RUN chmod a+rx /usr/local/bin/yt-dlp - -WORKDIR /app/run - -ENV ASPNETCORE_ENVIRONMENT=Production -ENV DOTNET_ENVIRONMENT=Production -ENV TZ=Asia/Singapore - -COPY --from=publish /app/publish . - -RUN mkdir -p wwwroot - -EXPOSE 5000 +# Keep your original structure but optimize base image +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS lib-build + +WORKDIR /deps + +RUN apt update && apt install -y --no-install-recommends \ + curl \ + libsodium-dev \ + libopus-dev \ + wget \ + unzip \ + && curl -fsSL https://bun.sh/install | bash \ + && ln -s /root/.bun/bin/bun /usr/local/bin/bun \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -O /tmp/libdave.zip \ + https://github.com/discord/libdave/releases/download/v1.1.1/cpp/libdave-Linux-X64-boringssl.zip && \ + unzip /tmp/libdave.zip -d /tmp/libdave && \ + cp /tmp/libdave/lib/*.so /usr/lib/ && \ + rm -rf /tmp/libdave /tmp/libdave.zip + +FROM lib-build AS build + +ARG BUILD_CONFIGURATION=Release +ARG PROJECT_PATH=src/Worker/Worker.csproj + +# The frontend calls the API via a relative /api path (see src/UI/App/.env.production), +# so the same image works locally and in deployment without a baked-in URL. + +WORKDIR /app + +COPY . . + +RUN dotnet restore "$PROJECT_PATH" + +RUN dotnet build "$PROJECT_PATH" \ + -c $BUILD_CONFIGURATION \ + -o /app/build \ + --os linux \ + --p:DebugSymbols=true \ + --p:DebugType=portable + +FROM build AS publish + +ARG BUILD_CONFIGURATION=Release +WORKDIR /app + +RUN dotnet publish "$PROJECT_PATH" \ + -c $BUILD_CONFIGURATION \ + -o /app/publish \ + --os linux \ + --self-contained false \ + --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final + +RUN apt update && apt install -y --no-install-recommends \ + ffmpeg \ + tzdata \ + python3 \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/share/zoneinfo/Asia/Singapore /etc/localtime + +# Install latest yt-dlp directly from GitHub releases +ARG YT_DLP_CACHE_BUST=latest +ADD https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp /usr/local/bin/yt-dlp +RUN chmod a+rx /usr/local/bin/yt-dlp + +WORKDIR /app/run + +ENV ASPNETCORE_ENVIRONMENT=Production +ENV DOTNET_ENVIRONMENT=Production +ENV TZ=Asia/Singapore + +COPY --from=publish /app/publish . + +RUN mkdir -p wwwroot + +EXPOSE 5000 ENTRYPOINT ["dotnet", "Worker.dll"] \ No newline at end of file diff --git a/deployment/docker-compose.dcproj b/deployment/docker-compose.dcproj index 2fdcf19..d17f173 100644 --- a/deployment/docker-compose.dcproj +++ b/deployment/docker-compose.dcproj @@ -1,18 +1,18 @@ - - - - 2.1 - Linux - False - bcbe5d8e-ddc7-4753-96ea-8168b0f30074 - - - - - - - - - - + + + + 2.1 + Linux + False + bcbe5d8e-ddc7-4753-96ea-8168b0f30074 + + + + + + + + + + \ No newline at end of file diff --git a/deployment/docker-compose.override.yml b/deployment/docker-compose.override.yml index e4e42da..975eb1f 100644 --- a/deployment/docker-compose.override.yml +++ b/deployment/docker-compose.override.yml @@ -1,8 +1,8 @@ -services: - radio-discord-bot: - ports: - - "5000:5000" - build: - args: - BUILD_CONFIGURATION: Debug - entrypoint: ["dotnet", "Worker.dll", "--wait-for-debugger"] +services: + radio-discord-bot: + ports: + - "5000:5000" + build: + args: + BUILD_CONFIGURATION: Debug + entrypoint: ["dotnet", "Worker.dll", "--wait-for-debugger"] diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 8d861b1..678ac2c 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -1,50 +1,50 @@ -services: - postgres: - image: postgres:latest - container_name: radio-postgres - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - - radio-discord-bot: - ports: - - "5000:5000" - container_name: discord-bot - image: ${DOCKER_REGISTRY-}radiodiscordbot - build: - context: .. - dockerfile: deployment/Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - environment: - - SpotifySettings__ClientId=${SPOTIFY_CLIENT_ID} - - SpotifySettings__ClientSecret=${SPOTIFY_CLIENT_SECRET} - - Discord__Token=${DISCORD_TOKEN} - - ConnectionStrings__DefaultConnection=Host=postgres;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD};Trust Server Certificate=true; - - WebsiteSettings__Url=${WEBSITE_URL} - - Cors__AllowedOrigins=[${CORS_ALLOWED_ORIGINS}] - - JwtSettings__Secret=${JWT_SETTINGS_SECRET} - - JwtSettings__InternalPassword=${JWT_SETTINGS_INTERNAL_PASSWORD} - - JwtSettings__Issuer=${WEBSITE_URL} - - JwtSettings__Audience=${WEBSITE_URL} - deploy: - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - -volumes: +services: + postgres: + image: postgres:latest + container_name: radio-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + radio-discord-bot: + ports: + - "5000:5000" + container_name: discord-bot + image: ${DOCKER_REGISTRY-}radiodiscordbot + build: + context: .. + dockerfile: deployment/Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + - SpotifySettings__ClientId=${SPOTIFY_CLIENT_ID} + - SpotifySettings__ClientSecret=${SPOTIFY_CLIENT_SECRET} + - Discord__Token=${DISCORD_TOKEN} + - ConnectionStrings__DefaultConnection=Host=postgres;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD};Trust Server Certificate=true; + - WebsiteSettings__Url=${WEBSITE_URL} + - Cors__AllowedOrigins=[${CORS_ALLOWED_ORIGINS}] + - JwtSettings__Secret=${JWT_SETTINGS_SECRET} + - JwtSettings__InternalPassword=${JWT_SETTINGS_INTERNAL_PASSWORD} + - JwtSettings__Issuer=${WEBSITE_URL} + - JwtSettings__Audience=${WEBSITE_URL} + deploy: + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + +volumes: postgres_data: \ No newline at end of file diff --git a/deployment/launchSettings.json b/deployment/launchSettings.json index 7eeedbd..64cd156 100644 --- a/deployment/launchSettings.json +++ b/deployment/launchSettings.json @@ -1,11 +1,11 @@ -{ - "profiles": { - "Docker Compose": { - "commandName": "DockerCompose", - "commandVersion": "1.0", - "serviceActions": { - "radio-discord-bot": "StartDebugging" - } - } - } +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "radio-discord-bot": "StartDebugging" + } + } + } } \ No newline at end of file diff --git a/discord-project.slnx b/discord-project.slnx index 510e5a8..7e88f54 100644 --- a/discord-project.slnx +++ b/discord-project.slnx @@ -1,16 +1,16 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/global.json b/global.json index a4e9cfa..a19ccb5 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ -{ - "sdk": { - "version": "9.0.3", - "rollForward": "latestMajor", - "allowPrerelease": false - } +{ + "sdk": { + "version": "9.0.3", + "rollForward": "latestMajor", + "allowPrerelease": false + } } \ No newline at end of file diff --git a/nuget.config b/nuget.config index 53af0ae..06372ff 100644 --- a/nuget.config +++ b/nuget.config @@ -1,6 +1,6 @@ - - - - - - + + + + + + diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 39d5226..e2a5fcd 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -1,16 +1,16 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/Application/AssemblyMarker.cs b/src/Application/AssemblyMarker.cs index b50ca1b..76025c5 100644 --- a/src/Application/AssemblyMarker.cs +++ b/src/Application/AssemblyMarker.cs @@ -1,3 +1,3 @@ -namespace Application; - +namespace Application; + public sealed class AssemblyMarker; \ No newline at end of file diff --git a/src/Application/Configs/ConfigurationHelper.cs b/src/Application/Configs/ConfigurationHelper.cs index 0de48e7..61d5f98 100644 --- a/src/Application/Configs/ConfigurationHelper.cs +++ b/src/Application/Configs/ConfigurationHelper.cs @@ -1,13 +1,13 @@ -using Microsoft.Extensions.Configuration; - -namespace Application.Configs; - -public static class ConfigurationHelper -{ - public static T GetConfiguration(this IConfiguration configuration, string section) - { - return configuration.GetSection(section).Get() - ?? throw new ArgumentNullException($"Section '{section}' not found or null"); - } -} - +using Microsoft.Extensions.Configuration; + +namespace Application.Configs; + +public static class ConfigurationHelper +{ + public static T GetConfiguration(this IConfiguration configuration, string section) + { + return configuration.GetSection(section).Get() + ?? throw new ArgumentNullException($"Section '{section}' not found or null"); + } +} + diff --git a/src/Application/DTOs/HelpMessageDto.cs b/src/Application/DTOs/HelpMessageDto.cs index 6987556..7c28ee3 100644 --- a/src/Application/DTOs/HelpMessageDto.cs +++ b/src/Application/DTOs/HelpMessageDto.cs @@ -1,7 +1,7 @@ -namespace Application.DTOs; - -public abstract class HelpMessageDto -{ - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; -} +namespace Application.DTOs; + +public abstract class HelpMessageDto +{ + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} diff --git a/src/Application/DTOs/JokeDto.cs b/src/Application/DTOs/JokeDto.cs index b790633..232aed5 100644 --- a/src/Application/DTOs/JokeDto.cs +++ b/src/Application/DTOs/JokeDto.cs @@ -1,21 +1,21 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs; - -public class JokeDto -{ - [JsonPropertyName("error")] - public bool Error { get; set; } - - [JsonPropertyName("category")] - public string Category { get; set; } = string.Empty; - - [JsonPropertyName("setup")] - public string Setup { get; set; } = string.Empty; - - [JsonPropertyName("delivery")] - public string Delivery { get; set; } = string.Empty; -} - - - +using System.Text.Json.Serialization; + +namespace Application.DTOs; + +public class JokeDto +{ + [JsonPropertyName("error")] + public bool Error { get; set; } + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("setup")] + public string Setup { get; set; } = string.Empty; + + [JsonPropertyName("delivery")] + public string Delivery { get; set; } = string.Empty; +} + + + diff --git a/src/Application/DTOs/Joke_QuoteSettingDto.cs b/src/Application/DTOs/Joke_QuoteSettingDto.cs index c5a47ae..76eab47 100644 --- a/src/Application/DTOs/Joke_QuoteSettingDto.cs +++ b/src/Application/DTOs/Joke_QuoteSettingDto.cs @@ -1,7 +1,7 @@ -namespace Application.DTOs; - -public class JokeQuoteSettingDto -{ - public string Greeting { get; set; } = string.Empty; - public string ApiUrl { get; set; } = string.Empty; -} +namespace Application.DTOs; + +public class JokeQuoteSettingDto +{ + public string Greeting { get; set; } = string.Empty; + public string ApiUrl { get; set; } = string.Empty; +} diff --git a/src/Application/DTOs/PlayRequest.cs b/src/Application/DTOs/PlayRequest.cs index 6dbee86..5bda8b3 100644 --- a/src/Application/DTOs/PlayRequest.cs +++ b/src/Application/DTOs/PlayRequest.cs @@ -1,20 +1,20 @@ -namespace Application.DTOs; - -// Non-generic base type (holds members that don't depend on T) -public abstract class PlayRequest -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public int RetryCount { get; set; } - public Func Callbacks { get; set; } = null!; - - public abstract object ContextAsObject { get; } - public string? VideoTitle { get; set; } - public string? VideoUrl { get; set; } -} - -public class PlayRequest : PlayRequest -{ - public TContext Context { get; init; } = default!; - - public override object ContextAsObject => Context!; -} +namespace Application.DTOs; + +// Non-generic base type (holds members that don't depend on T) +public abstract class PlayRequest +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public int RetryCount { get; set; } + public Func Callbacks { get; set; } = null!; + + public abstract object ContextAsObject { get; } + public string? VideoTitle { get; set; } + public string? VideoUrl { get; set; } +} + +public class PlayRequest : PlayRequest +{ + public TContext Context { get; init; } = default!; + + public override object ContextAsObject => Context!; +} diff --git a/src/Application/DTOs/PlayStateDto.cs b/src/Application/DTOs/PlayStateDto.cs index 5325828..4f1afc7 100644 --- a/src/Application/DTOs/PlayStateDto.cs +++ b/src/Application/DTOs/PlayStateDto.cs @@ -1,8 +1,8 @@ -namespace Application.DTOs; - -public class PlayStateDto -{ - public bool IsPlaying { get; set; } = false; - - public bool IsRadioPlaying { get; set; } = false; +namespace Application.DTOs; + +public class PlayStateDto +{ + public bool IsPlaying { get; set; } = false; + + public bool IsRadioPlaying { get; set; } = false; } \ No newline at end of file diff --git a/src/Application/DTOs/QuoteDto.cs b/src/Application/DTOs/QuoteDto.cs index 32f63e2..0d4822d 100644 --- a/src/Application/DTOs/QuoteDto.cs +++ b/src/Application/DTOs/QuoteDto.cs @@ -1,18 +1,18 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs; - -public class QuoteDto -{ - [JsonPropertyName("author")] - public string Author { get; set; } = string.Empty; - - - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - - - [JsonPropertyName("tags")] - public List Tags { get; set; } = new(); -} - +using System.Text.Json.Serialization; + +namespace Application.DTOs; + +public class QuoteDto +{ + [JsonPropertyName("author")] + public string Author { get; set; } = string.Empty; + + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + + + [JsonPropertyName("tags")] + public List Tags { get; set; } = new(); +} + diff --git a/src/Application/DTOs/SongDto.cs b/src/Application/DTOs/SongDto.cs index 4e6debe..7d8cdc9 100644 --- a/src/Application/DTOs/SongDto.cs +++ b/src/Application/DTOs/SongDto.cs @@ -1,13 +1,13 @@ -namespace Application.DTOs; - -public class SongDto : SongDtoBase where TVoiceChannel : class -{ - public required TVoiceChannel? VoiceChannel { get; init; } -} - -public class SongDtoBase -{ - public required string Url { get; set; } - public string? Title { get; set; } - public required ulong UserId { get; init; } +namespace Application.DTOs; + +public class SongDto : SongDtoBase where TVoiceChannel : class +{ + public required TVoiceChannel? VoiceChannel { get; init; } +} + +public class SongDtoBase +{ + public required string Url { get; set; } + public string? Title { get; set; } + public required ulong UserId { get; init; } } \ No newline at end of file diff --git a/src/Application/DTOs/Spotify/ArtistDto.cs b/src/Application/DTOs/Spotify/ArtistDto.cs index 887a665..f12ad6d 100644 --- a/src/Application/DTOs/Spotify/ArtistDto.cs +++ b/src/Application/DTOs/Spotify/ArtistDto.cs @@ -1,15 +1,15 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Spotify; - -public class ArtistDto -{ - [JsonPropertyName("genres")] - [field: AllowNull, MaybeNull] - public string[] Genres - { - get => field ?? []; - set => field = value.Take(5).ToArray(); - } +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Application.DTOs.Spotify; + +public class ArtistDto +{ + [JsonPropertyName("genres")] + [field: AllowNull, MaybeNull] + public string[] Genres + { + get => field ?? []; + set => field = value.Take(5).ToArray(); + } } \ No newline at end of file diff --git a/src/Application/DTOs/Spotify/AuthDto.cs b/src/Application/DTOs/Spotify/AuthDto.cs index 610c38a..8a77019 100644 --- a/src/Application/DTOs/Spotify/AuthDto.cs +++ b/src/Application/DTOs/Spotify/AuthDto.cs @@ -1,12 +1,12 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs.Spotify; - -public class AuthDto -{ - [JsonPropertyName("access_token")] - public string? AccessToken { get; set; } - [JsonPropertyName("expires_in")] - public int ExpiresIn { get; set; } - public DateTimeOffset TimeStamp { get; set; } = DateTimeOffset.UtcNow; +using System.Text.Json.Serialization; + +namespace Application.DTOs.Spotify; + +public class AuthDto +{ + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + public DateTimeOffset TimeStamp { get; set; } = DateTimeOffset.UtcNow; } \ No newline at end of file diff --git a/src/Application/DTOs/Spotify/RecommendationDto.cs b/src/Application/DTOs/Spotify/RecommendationDto.cs index 5c09548..b68057d 100644 --- a/src/Application/DTOs/Spotify/RecommendationDto.cs +++ b/src/Application/DTOs/Spotify/RecommendationDto.cs @@ -1,9 +1,9 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs.Spotify; - -public class RecommendationDto -{ - [JsonPropertyName("tracks")] - public Items[] Tracks { get; set; } = []; -} +using System.Text.Json.Serialization; + +namespace Application.DTOs.Spotify; + +public class RecommendationDto +{ + [JsonPropertyName("tracks")] + public Items[] Tracks { get; set; } = []; +} diff --git a/src/Application/DTOs/Spotify/SearchDto.cs b/src/Application/DTOs/Spotify/SearchDto.cs index 55bae22..d504cf0 100644 --- a/src/Application/DTOs/Spotify/SearchDto.cs +++ b/src/Application/DTOs/Spotify/SearchDto.cs @@ -1,9 +1,9 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs.Spotify; - -public class SearchDto -{ - [JsonPropertyName("tracks")] - public TracksDto Tracks { get; set; } = new(); +using System.Text.Json.Serialization; + +namespace Application.DTOs.Spotify; + +public class SearchDto +{ + [JsonPropertyName("tracks")] + public TracksDto Tracks { get; set; } = new(); } \ No newline at end of file diff --git a/src/Application/DTOs/Spotify/TracksDto.cs b/src/Application/DTOs/Spotify/TracksDto.cs index d3fee93..8831273 100644 --- a/src/Application/DTOs/Spotify/TracksDto.cs +++ b/src/Application/DTOs/Spotify/TracksDto.cs @@ -1,25 +1,25 @@ -using System.Text.Json.Serialization; - -namespace Application.DTOs.Spotify; - -public class TracksDto -{ - [JsonPropertyName("items")] - public Items[] Items { get; set; } = []; -} - -public class Items : BaseSearch -{ - [JsonPropertyName("artists")] - public Artists[] Artists { get; set; } = []; -} - -public class Artists : BaseSearch; - -public class BaseSearch -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; +using System.Text.Json.Serialization; + +namespace Application.DTOs.Spotify; + +public class TracksDto +{ + [JsonPropertyName("items")] + public Items[] Items { get; set; } = []; +} + +public class Items : BaseSearch +{ + [JsonPropertyName("artists")] + public Artists[] Artists { get; set; } = []; +} + +public class Artists : BaseSearch; + +public class BaseSearch +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/Application/DTOs/Stats/RecentPlayDto.cs b/src/Application/DTOs/Stats/RecentPlayDto.cs index c75fdd3..a194484 100644 --- a/src/Application/DTOs/Stats/RecentPlayDto.cs +++ b/src/Application/DTOs/Stats/RecentPlayDto.cs @@ -1,7 +1,7 @@ -namespace Application.DTOs.Stats; - -public class RecentPlayDto -{ - public string Title { get; init; } = string.Empty; - public required DateTimeOffset PlayedAt { get; init; } +namespace Application.DTOs.Stats; + +public class RecentPlayDto +{ + public string Title { get; init; } = string.Empty; + public required DateTimeOffset PlayedAt { get; init; } } \ No newline at end of file diff --git a/src/Application/DTOs/Stats/RecentSongDto.cs b/src/Application/DTOs/Stats/RecentSongDto.cs index 64e2caa..89fc4ad 100644 --- a/src/Application/DTOs/Stats/RecentSongDto.cs +++ b/src/Application/DTOs/Stats/RecentSongDto.cs @@ -1,8 +1,8 @@ -namespace Application.DTOs.Stats; - -public class RecentSongDto -{ - public string Title { get; init; } = string.Empty; - public int TotalPlays { get; init; } - public required DateTimeOffset PlayedAt { get; init; } -} +namespace Application.DTOs.Stats; + +public class RecentSongDto +{ + public string Title { get; init; } = string.Empty; + public int TotalPlays { get; init; } + public required DateTimeOffset PlayedAt { get; init; } +} diff --git a/src/Application/DTOs/Stats/TopSongDto.cs b/src/Application/DTOs/Stats/TopSongDto.cs index f68b654..e2f5b96 100644 --- a/src/Application/DTOs/Stats/TopSongDto.cs +++ b/src/Application/DTOs/Stats/TopSongDto.cs @@ -1,9 +1,9 @@ -namespace Application.DTOs.Stats; - -public class TopSongDto -{ - public string Title { get; init; } = string.Empty; - public string? Artist { get; set; } - public int PlayCount { get; init; } - public required DateTimeOffset LastPlayed { get; init; } +namespace Application.DTOs.Stats; + +public class TopSongDto +{ + public string Title { get; init; } = string.Empty; + public string? Artist { get; set; } + public int PlayCount { get; init; } + public required DateTimeOffset LastPlayed { get; init; } } \ No newline at end of file diff --git a/src/Application/DTOs/Stats/UserStatsDto.cs b/src/Application/DTOs/Stats/UserStatsDto.cs index 6ffbb61..77c90dd 100644 --- a/src/Application/DTOs/Stats/UserStatsDto.cs +++ b/src/Application/DTOs/Stats/UserStatsDto.cs @@ -1,12 +1,12 @@ -namespace Application.DTOs.Stats; - -public class UserStatsDto -{ - public string Username { get; init; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public int TotalPlays { get; init; } - public int UniqueSongs { get; init; } - public required DateTimeOffset MemberSince { get; init; } - public DateTimeOffset? LastPlayed { get; set; } = null; - public IReadOnlyCollection RecentSongs { get; init; } = []; +namespace Application.DTOs.Stats; + +public class UserStatsDto +{ + public string Username { get; init; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public int TotalPlays { get; init; } + public int UniqueSongs { get; init; } + public required DateTimeOffset MemberSince { get; init; } + public DateTimeOffset? LastPlayed { get; set; } = null; + public IReadOnlyCollection RecentSongs { get; init; } = []; } \ No newline at end of file diff --git a/src/Application/DTOs/TrackPlayResult.cs b/src/Application/DTOs/TrackPlayResult.cs index 192a481..a0b5180 100644 --- a/src/Application/DTOs/TrackPlayResult.cs +++ b/src/Application/DTOs/TrackPlayResult.cs @@ -1,16 +1,16 @@ -namespace Application.DTOs; - -public enum TrackPlayResult -{ - /// The track played to the end (ffmpeg exit code 0). - Completed, - - /// Playback failed (non-zero exit code or source resolution failure); candidate for retry. - Failed, - - /// Playback was cancelled via the track cancellation token (skip or stop). - Skipped, - - /// The requesting user is not in a voice channel; the track is dropped. - NotInVoiceChannel -} +namespace Application.DTOs; + +public enum TrackPlayResult +{ + /// The track played to the end (ffmpeg exit code 0). + Completed, + + /// Playback failed (non-zero exit code or source resolution failure); candidate for retry. + Failed, + + /// Playback was cancelled via the track cancellation token (skip or stop). + Skipped, + + /// The requesting user is not in a voice channel; the track is dropped. + NotInVoiceChannel +} diff --git a/src/Application/Eventing/AsyncEventDispatcher.cs b/src/Application/Eventing/AsyncEventDispatcher.cs index 08c05f4..72a303e 100644 --- a/src/Application/Eventing/AsyncEventDispatcher.cs +++ b/src/Application/Eventing/AsyncEventDispatcher.cs @@ -1,17 +1,17 @@ -using Domain.Common; -using Domain.Eventing; -using Domain.Events; -using Microsoft.Extensions.DependencyInjection; - -namespace Application.Eventing; - -public sealed class AsyncEventDispatcher(IServiceProvider serviceProvider, HandlerRegistry handlerRegistry) : IAsyncEventDispatcher -{ - public async Task DispatchAsync(TEvent @event, CancellationToken ct = default) where TEvent : IEvent - { - var tasks = handlerRegistry.GetAsyncHandlers(typeof(TEvent)) - .Select(t => ((IAsyncEventHandler)serviceProvider.GetRequiredService(t)).HandleAsync(@event, ct)); - - await Task.WhenAll(tasks); - } +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.Eventing; + +public sealed class AsyncEventDispatcher(IServiceProvider serviceProvider, HandlerRegistry handlerRegistry) : IAsyncEventDispatcher +{ + public async Task DispatchAsync(TEvent @event, CancellationToken ct = default) where TEvent : IEvent + { + var tasks = handlerRegistry.GetAsyncHandlers(typeof(TEvent)) + .Select(t => ((IAsyncEventHandler)serviceProvider.GetRequiredService(t)).HandleAsync(@event, ct)); + + await Task.WhenAll(tasks); + } } \ No newline at end of file diff --git a/src/Application/Eventing/EventDispatcher.cs b/src/Application/Eventing/EventDispatcher.cs index a743f66..5a312e0 100644 --- a/src/Application/Eventing/EventDispatcher.cs +++ b/src/Application/Eventing/EventDispatcher.cs @@ -1,19 +1,19 @@ -using Domain.Common; -using Domain.Eventing; -using Domain.Events; -using Microsoft.Extensions.DependencyInjection; - -namespace Application.Eventing; - -public class EventDispatcher(IServiceProvider serviceProvider, HandlerRegistry handlerRegistry) : IEventDispatcher -{ - public void Dispatch(TEvent @event) where TEvent : IEvent - { - var tasks = handlerRegistry.GetSyncHandlers(@event.GetType()); - foreach (var handlerType in tasks) - { - var handler = (IEventHandler)serviceProvider.GetRequiredService(handlerType); - handler.Handle(@event); - } - } +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.Eventing; + +public class EventDispatcher(IServiceProvider serviceProvider, HandlerRegistry handlerRegistry) : IEventDispatcher +{ + public void Dispatch(TEvent @event) where TEvent : IEvent + { + var tasks = handlerRegistry.GetSyncHandlers(@event.GetType()); + foreach (var handlerType in tasks) + { + var handler = (IEventHandler)serviceProvider.GetRequiredService(handlerType); + handler.Handle(@event); + } + } } \ No newline at end of file diff --git a/src/Application/Eventing/EventingServiceCollectionExtensions.cs b/src/Application/Eventing/EventingServiceCollectionExtensions.cs index d0689cf..5df55b3 100644 --- a/src/Application/Eventing/EventingServiceCollectionExtensions.cs +++ b/src/Application/Eventing/EventingServiceCollectionExtensions.cs @@ -1,77 +1,77 @@ -using System.Reflection; -using Domain.Eventing; -using Microsoft.Extensions.DependencyInjection; - -namespace Application.Eventing; - -public static class EventingServiceCollectionExtensions -{ - public static void AddEventing(this IServiceCollection services, params Assembly[] assemblies) - { - // Get all handler types once - var handlerTypes = GetEventHandlerTypes(assemblies); - - // One singleton registry, populated via factory - services.AddSingleton(_ => BuildRegistry(handlerTypes)); - - // Dispatcher uses the same registry and current scope's services - services.AddScoped(); - services.AddScoped(); - - // Register all handler types found by scanning - foreach (var type in handlerTypes) - { - services.AddScoped(type); - } - } - - private static HandlerRegistry BuildRegistry(IEnumerable handlerTypes) - { - var registry = new HandlerRegistry(); - - handlerTypes - .SelectMany(type => type.GetInterfaces() - .Where(i => i.IsGenericType && - (i.GetGenericTypeDefinition() == typeof(IEventHandler<>) || - i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>))) - .Select(i => new { Interface = i, HandlerType = type })) - .ToList() - .ForEach(item => - { - var isAsync = item.Interface.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>); - registry.Register(item.Interface.GetGenericArguments()[0], item.HandlerType, isAsync); - }); - - return registry; - } - - private static List GetEventHandlerTypes(Assembly[] assemblies) - { - return assemblies.Distinct() - .SelectMany(GetTypesFromAssembly) - .Where(t => t is { IsAbstract: false, IsInterface: false }) - .Where(IsEventHandlerType) - .ToList(); - } - - private static Type[] GetTypesFromAssembly(Assembly assembly) - { - try - { - return assembly.GetTypes(); - } - catch (ReflectionTypeLoadException ex) - { - // Use only the types that could be loaded successfully - return ex.Types.Where(t => t != null).ToArray()!; - } - } - - private static bool IsEventHandlerType(Type type) - { - return type.GetInterfaces() - .Any(i => i.IsGenericType && - (i.GetGenericTypeDefinition() == typeof(IEventHandler<>) || - i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>))); - } -} +using System.Reflection; +using Domain.Eventing; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.Eventing; + +public static class EventingServiceCollectionExtensions +{ + public static void AddEventing(this IServiceCollection services, params Assembly[] assemblies) + { + // Get all handler types once + var handlerTypes = GetEventHandlerTypes(assemblies); + + // One singleton registry, populated via factory + services.AddSingleton(_ => BuildRegistry(handlerTypes)); + + // Dispatcher uses the same registry and current scope's services + services.AddScoped(); + services.AddScoped(); + + // Register all handler types found by scanning + foreach (var type in handlerTypes) + { + services.AddScoped(type); + } + } + + private static HandlerRegistry BuildRegistry(IEnumerable handlerTypes) + { + var registry = new HandlerRegistry(); + + handlerTypes + .SelectMany(type => type.GetInterfaces() + .Where(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IEventHandler<>) || + i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>))) + .Select(i => new { Interface = i, HandlerType = type })) + .ToList() + .ForEach(item => + { + var isAsync = item.Interface.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>); + registry.Register(item.Interface.GetGenericArguments()[0], item.HandlerType, isAsync); + }); + + return registry; + } + + private static List GetEventHandlerTypes(Assembly[] assemblies) + { + return assemblies.Distinct() + .SelectMany(GetTypesFromAssembly) + .Where(t => t is { IsAbstract: false, IsInterface: false }) + .Where(IsEventHandlerType) + .ToList(); + } + + private static Type[] GetTypesFromAssembly(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // Use only the types that could be loaded successfully + return ex.Types.Where(t => t != null).ToArray()!; + } + } + + private static bool IsEventHandlerType(Type type) + { + return type.GetInterfaces() + .Any(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IEventHandler<>) || + i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>))); + } +} diff --git a/src/Application/Eventing/HandlerRegistry.cs b/src/Application/Eventing/HandlerRegistry.cs index ca4204a..1860e72 100644 --- a/src/Application/Eventing/HandlerRegistry.cs +++ b/src/Application/Eventing/HandlerRegistry.cs @@ -1,24 +1,24 @@ -using System.Collections.Concurrent; - -namespace Application.Eventing; - -public class HandlerRegistry -{ - private readonly ConcurrentDictionary _map = new(); - - public void Register(Type eventType, Type handlerType, bool isAsync = true) - { - _map.AddOrUpdate(eventType, - _ => isAsync ? ([], [handlerType]) - : ([handlerType], []), - (_, tuple) => isAsync - ? (tuple.sync, tuple.async.Concat([handlerType]).ToArray()) - : (tuple.sync.Concat([handlerType]).ToArray(), tuple.async)); - } - - public IEnumerable GetSyncHandlers(Type eventType) => - _map.TryGetValue(eventType, out var handlers) ? handlers.sync : []; - - public IEnumerable GetAsyncHandlers(Type eventType) => - _map.TryGetValue(eventType, out var handlers) ? handlers.async : []; +using System.Collections.Concurrent; + +namespace Application.Eventing; + +public class HandlerRegistry +{ + private readonly ConcurrentDictionary _map = new(); + + public void Register(Type eventType, Type handlerType, bool isAsync = true) + { + _map.AddOrUpdate(eventType, + _ => isAsync ? ([], [handlerType]) + : ([handlerType], []), + (_, tuple) => isAsync + ? (tuple.sync, tuple.async.Concat([handlerType]).ToArray()) + : (tuple.sync.Concat([handlerType]).ToArray(), tuple.async)); + } + + public IEnumerable GetSyncHandlers(Type eventType) => + _map.TryGetValue(eventType, out var handlers) ? handlers.sync : []; + + public IEnumerable GetAsyncHandlers(Type eventType) => + _map.TryGetValue(eventType, out var handlers) ? handlers.async : []; } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IBlacklistService.cs b/src/Application/Interfaces/Services/IBlacklistService.cs index 67f21eb..fce2994 100644 --- a/src/Application/Interfaces/Services/IBlacklistService.cs +++ b/src/Application/Interfaces/Services/IBlacklistService.cs @@ -1,21 +1,21 @@ -using Domain.Entities; - -namespace Application.Interfaces.Services; - -public interface IBlacklistService -{ - /// - /// Marks the song with the given source URL as blacklisted. - /// Returns false when no song with that URL exists. - /// - Task AddToBlacklistAsync(string sourceUrl); - - /// - /// Removes the first song whose title contains the given text from the blacklist. - /// Returns false when no matching song exists. - /// - Task RemoveFromBlacklistAsync(string title); - - Task IsBlacklistedAsync(string sourceUrl); - Task> GetBlacklistedSongsAsync(); -} +using Domain.Entities; + +namespace Application.Interfaces.Services; + +public interface IBlacklistService +{ + /// + /// Marks the song with the given source URL as blacklisted. + /// Returns false when no song with that URL exists. + /// + Task AddToBlacklistAsync(string sourceUrl); + + /// + /// Removes the first song whose title contains the given text from the blacklist. + /// Returns false when no matching song exists. + /// + Task RemoveFromBlacklistAsync(string title); + + Task IsBlacklistedAsync(string sourceUrl); + Task> GetBlacklistedSongsAsync(); +} diff --git a/src/Application/Interfaces/Services/IGuildMusicService.cs b/src/Application/Interfaces/Services/IGuildMusicService.cs index 3339ccd..9789fbd 100644 --- a/src/Application/Interfaces/Services/IGuildMusicService.cs +++ b/src/Application/Interfaces/Services/IGuildMusicService.cs @@ -1,46 +1,46 @@ -using Application.DTOs; - -namespace Application.Interfaces.Services; - -/// -/// Guild-scoped facade over the per-guild music players. Commands and interactions -/// address a specific guild's queue and playback through this interface; players are -/// created lazily on the first enqueue for a guild. -/// -public interface IGuildMusicService -{ - void Enqueue(ulong guildId, PlayRequest request); - - /// - /// The request currently being played in the guild, or null when the guild has no - /// player or nothing is playing. - /// - PlayRequest? GetNowPlaying(ulong guildId); - - /// - /// Snapshot of the guild's queue: the currently playing request first (if any), - /// then pending ones. Empty when the guild has no player. - /// - PlayRequest[] GetAllRequests(ulong guildId); - - /// - /// Number of pending requests in the guild (excludes the currently playing one). - /// - int GetQueueCount(ulong guildId); - - /// - /// Re-queues the guild's currently playing request at the front so it plays again. - /// - void Rewind(ulong guildId); - - /// - /// Cancels the guild's current track. No-op when the guild has no player. - /// - void Skip(ulong guildId); - - /// - /// Clears the guild's queue and cancels its current track. No-op when the guild - /// has no player. - /// - void Stop(ulong guildId); -} +using Application.DTOs; + +namespace Application.Interfaces.Services; + +/// +/// Guild-scoped facade over the per-guild music players. Commands and interactions +/// address a specific guild's queue and playback through this interface; players are +/// created lazily on the first enqueue for a guild. +/// +public interface IGuildMusicService +{ + void Enqueue(ulong guildId, PlayRequest request); + + /// + /// The request currently being played in the guild, or null when the guild has no + /// player or nothing is playing. + /// + PlayRequest? GetNowPlaying(ulong guildId); + + /// + /// Snapshot of the guild's queue: the currently playing request first (if any), + /// then pending ones. Empty when the guild has no player. + /// + PlayRequest[] GetAllRequests(ulong guildId); + + /// + /// Number of pending requests in the guild (excludes the currently playing one). + /// + int GetQueueCount(ulong guildId); + + /// + /// Re-queues the guild's currently playing request at the front so it plays again. + /// + void Rewind(ulong guildId); + + /// + /// Cancels the guild's current track. No-op when the guild has no player. + /// + void Skip(ulong guildId); + + /// + /// Clears the guild's queue and cancels its current track. No-op when the guild + /// has no player. + /// + void Stop(ulong guildId); +} diff --git a/src/Application/Interfaces/Services/IHttpRequestService.cs b/src/Application/Interfaces/Services/IHttpRequestService.cs index 03a1b8a..753093c 100644 --- a/src/Application/Interfaces/Services/IHttpRequestService.cs +++ b/src/Application/Interfaces/Services/IHttpRequestService.cs @@ -1,9 +1,9 @@ -using Domain.Common.Enums; - -namespace Application.Interfaces.Services; - -public interface IHttpRequestService -{ - Task GetAsync(string url, object? data = null, string? token = null); - Task PostAsync(string url, object data, PostRequestMediaType mediaType = PostRequestMediaType.Json); -} +using Domain.Common.Enums; + +namespace Application.Interfaces.Services; + +public interface IHttpRequestService +{ + Task GetAsync(string url, object? data = null, string? token = null); + Task PostAsync(string url, object data, PostRequestMediaType mediaType = PostRequestMediaType.Json); +} diff --git a/src/Application/Interfaces/Services/IMusicQueueService.cs b/src/Application/Interfaces/Services/IMusicQueueService.cs index 82a8004..54b0d6a 100644 --- a/src/Application/Interfaces/Services/IMusicQueueService.cs +++ b/src/Application/Interfaces/Services/IMusicQueueService.cs @@ -1,38 +1,38 @@ -using Application.DTOs; - -namespace Application.Interfaces.Services; - -public interface IMusicQueueService -{ - void Enqueue(PlayRequest request); - - /// - /// Waits until a request is available and removes it from the pending queue. - /// Intended to be consumed by a single background consumer. - /// - ValueTask> DequeueAsync(CancellationToken cancellationToken); - - /// - /// Number of pending requests (excludes the currently playing one). - /// - int Count { get; } - - /// - /// The request currently being played, if any. Owned by the player background service. - /// - PlayRequest? NowPlaying { get; } - - void SetNowPlaying(PlayRequest? request); - - /// - /// Snapshot of the queue: the currently playing request first (if any), then pending ones. - /// - PlayRequest[] GetAllRequests(); - - /// - /// Re-queues the currently playing request at the front so it plays again. - /// - void Rewind(); - - void Clear(); -} +using Application.DTOs; + +namespace Application.Interfaces.Services; + +public interface IMusicQueueService +{ + void Enqueue(PlayRequest request); + + /// + /// Waits until a request is available and removes it from the pending queue. + /// Intended to be consumed by a single background consumer. + /// + ValueTask> DequeueAsync(CancellationToken cancellationToken); + + /// + /// Number of pending requests (excludes the currently playing one). + /// + int Count { get; } + + /// + /// The request currently being played, if any. Owned by the player background service. + /// + PlayRequest? NowPlaying { get; } + + void SetNowPlaying(PlayRequest? request); + + /// + /// Snapshot of the queue: the currently playing request first (if any), then pending ones. + /// + PlayRequest[] GetAllRequests(); + + /// + /// Re-queues the currently playing request at the front so it plays again. + /// + void Rewind(); + + void Clear(); +} diff --git a/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs b/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs index 3b096cb..90d2a2d 100644 --- a/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs +++ b/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs @@ -1,16 +1,16 @@ -using System.Diagnostics; - -namespace Application.Interfaces.Services; - -public interface INativePlaceMusicProcessorService -{ - /// - /// Stops any previously running process and starts a new ffmpeg process decoding the given URL to PCM on stdout. - /// - Task CreateStreamAsync(string audioUrl, CancellationToken cancellationToken); - - /// - /// Gracefully terminates the current ffmpeg process (stdin "q", then kill after a grace period). - /// - Task StopCurrentProcessAsync(); -} +using System.Diagnostics; + +namespace Application.Interfaces.Services; + +public interface INativePlaceMusicProcessorService +{ + /// + /// Stops any previously running process and starts a new ffmpeg process decoding the given URL to PCM on stdout. + /// + Task CreateStreamAsync(string audioUrl, CancellationToken cancellationToken); + + /// + /// Gracefully terminates the current ffmpeg process (stdin "q", then kill after a grace period). + /// + Task StopCurrentProcessAsync(); +} diff --git a/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs b/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs index a950b8a..e0a2127 100644 --- a/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs +++ b/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs @@ -1,17 +1,17 @@ -using Application.DTOs; - -namespace Application.Interfaces.Services; - -public interface INetCordAudioPlayerService -{ - /// - /// Plays a single request to completion. Joins the voice channel when not connected. - /// Returns when the track finishes, fails, or is cancelled. - /// - Task PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken); - - /// - /// Leaves the voice channel and releases the voice client. - /// - Task DisconnectAsync(); -} +using Application.DTOs; + +namespace Application.Interfaces.Services; + +public interface INetCordAudioPlayerService +{ + /// + /// Plays a single request to completion. Joins the voice channel when not connected. + /// Returns when the track finishes, fails, or is cancelled. + /// + Task PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken); + + /// + /// Leaves the voice channel and releases the voice client. + /// + Task DisconnectAsync(); +} diff --git a/src/Application/Interfaces/Services/IRadioSourceService.cs b/src/Application/Interfaces/Services/IRadioSourceService.cs index 4b7ceb4..0c4ae53 100644 --- a/src/Application/Interfaces/Services/IRadioSourceService.cs +++ b/src/Application/Interfaces/Services/IRadioSourceService.cs @@ -1,12 +1,12 @@ -using Domain.Entities; - -namespace Application.Interfaces.Services; - -public interface IRadioSourceService -{ - Task> GetAllRadioSourcesAsync(CancellationToken cancellationToken); - Task GetRadioSourceByIdAsync(Guid id, CancellationToken cancellationToken); - Task UpdateRadioSourceUrlAsync(Guid id, string name, string newSourceUrl, bool isActive, CancellationToken cancellationToken); - Task AddRadioSourceAsync(string name, string sourceUrl, CancellationToken cancellationToken); - Task DeleteRadioSourceAsync(Guid id, CancellationToken cancellationToken); +using Domain.Entities; + +namespace Application.Interfaces.Services; + +public interface IRadioSourceService +{ + Task> GetAllRadioSourcesAsync(CancellationToken cancellationToken); + Task GetRadioSourceByIdAsync(Guid id, CancellationToken cancellationToken); + Task UpdateRadioSourceUrlAsync(Guid id, string name, string newSourceUrl, bool isActive, CancellationToken cancellationToken); + Task AddRadioSourceAsync(string name, string sourceUrl, CancellationToken cancellationToken); + Task DeleteRadioSourceAsync(Guid id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IRandomService.cs b/src/Application/Interfaces/Services/IRandomService.cs index 2f0b277..ff171a4 100644 --- a/src/Application/Interfaces/Services/IRandomService.cs +++ b/src/Application/Interfaces/Services/IRandomService.cs @@ -1,6 +1,6 @@ -namespace Application.Interfaces.Services; - -public interface IRandomService -{ - Task GetAsync(); -} +namespace Application.Interfaces.Services; + +public interface IRandomService +{ + Task GetAsync(); +} diff --git a/src/Application/Interfaces/Services/IScopeExecutor.cs b/src/Application/Interfaces/Services/IScopeExecutor.cs index e9ad715..54e1952 100644 --- a/src/Application/Interfaces/Services/IScopeExecutor.cs +++ b/src/Application/Interfaces/Services/IScopeExecutor.cs @@ -1,7 +1,7 @@ -namespace Application.Interfaces.Services; - -public interface IScopeExecutor -{ - Task ExecuteAsync(Func action); - void Execute(Action action); +namespace Application.Interfaces.Services; + +public interface IScopeExecutor +{ + Task ExecuteAsync(Func action); + void Execute(Action action); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/ISpotifyService.cs b/src/Application/Interfaces/Services/ISpotifyService.cs index 9c798c0..b1b7fd9 100644 --- a/src/Application/Interfaces/Services/ISpotifyService.cs +++ b/src/Application/Interfaces/Services/ISpotifyService.cs @@ -1,6 +1,6 @@ -namespace Application.Interfaces.Services; - -public interface ISpotifyService -{ - Task GetRecommendationAsync(string songTitle); +namespace Application.Interfaces.Services; + +public interface ISpotifyService +{ + Task GetRecommendationAsync(string songTitle); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IStatisticsService.cs b/src/Application/Interfaces/Services/IStatisticsService.cs index 629af9b..5b79aef 100644 --- a/src/Application/Interfaces/Services/IStatisticsService.cs +++ b/src/Application/Interfaces/Services/IStatisticsService.cs @@ -1,14 +1,14 @@ -using Application.DTOs; -using Application.DTOs.Stats; - -namespace Application.Interfaces.Services; - -public interface IStatisticsService -{ - Task LogSongPlayAsync(ulong id, string userName, string globalName, SongDtoBase songDto); - Task> GetUserTopSongsAsync(ulong userId, int limit = 10); - Task GetUserStatsAsync(ulong userId); - Task> GetUserRecentPlaysAsync(ulong userId, int limit = 10); - Task> GetTopSongsAsync(bool isToday = false, int limit = 10); - Task> GetAllSongsAsync(); +using Application.DTOs; +using Application.DTOs.Stats; + +namespace Application.Interfaces.Services; + +public interface IStatisticsService +{ + Task LogSongPlayAsync(ulong id, string userName, string globalName, SongDtoBase songDto); + Task> GetUserTopSongsAsync(ulong userId, int limit = 10); + Task GetUserStatsAsync(ulong userId); + Task> GetUserRecentPlaysAsync(ulong userId, int limit = 10); + Task> GetTopSongsAsync(bool isToday = false, int limit = 10); + Task> GetAllSongsAsync(); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IStreamService.cs b/src/Application/Interfaces/Services/IStreamService.cs index d4298a8..8c00517 100644 --- a/src/Application/Interfaces/Services/IStreamService.cs +++ b/src/Application/Interfaces/Services/IStreamService.cs @@ -1,7 +1,7 @@ -namespace Application.Interfaces.Services; - -public interface IStreamService -{ - Task GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken); - Task GetVideoTitleAsync(string url, CancellationToken cancellationToken); +namespace Application.Interfaces.Services; + +public interface IStreamService +{ + Task GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken); + Task GetVideoTitleAsync(string url, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IUserService.cs b/src/Application/Interfaces/Services/IUserService.cs index fadbf89..59bc240 100644 --- a/src/Application/Interfaces/Services/IUserService.cs +++ b/src/Application/Interfaces/Services/IUserService.cs @@ -1,11 +1,11 @@ -using Application.DTOs.Stats; -using Domain.Entities; - -namespace Application.Interfaces.Services; - -public interface IUserService -{ - Task GetUserByUsernameAsync(string username); - Task GetUserByDisplayNameAsync(string displayName); - Task> GetAllUsersAsync(); +using Application.DTOs.Stats; +using Domain.Entities; + +namespace Application.Interfaces.Services; + +public interface IUserService +{ + Task GetUserByUsernameAsync(string username); + Task GetUserByDisplayNameAsync(string displayName); + Task> GetAllUsersAsync(); } \ No newline at end of file diff --git a/src/Application/Services/HttpRequestService.cs b/src/Application/Services/HttpRequestService.cs index 925f442..39f76a6 100644 --- a/src/Application/Services/HttpRequestService.cs +++ b/src/Application/Services/HttpRequestService.cs @@ -1,73 +1,73 @@ -using System.Net.Http.Headers; -using System.Reflection; -using System.Text; -using System.Text.Json; -using Application.Interfaces.Services; -using Domain.Common.Enums; - -namespace Application.Services; - -public class HttpRequestService(IHttpClientFactory httpClientFactory) : IHttpRequestService -{ - public async Task GetAsync(string url, object? data = null, string? token = null) - { - var httpClient = httpClientFactory.CreateClient(); - if (!string.IsNullOrEmpty(token)) - { - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - - if (data is not null) - { - var query = string.Join("&", data.GetType().GetProperties() - .Select(x => - $"{Uri.EscapeDataString(x.Name)}={Uri.EscapeDataString(x.GetValue(data)?.ToString() ?? string.Empty)}")); - url += $"?{query}"; - } - - var response = await httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(content) ?? - throw new HttpRequestException( - $"Error deserializing data from the server. Status code: {response.StatusCode}"); - } - - public async Task PostAsync(string url, object data, - PostRequestMediaType mediaType = PostRequestMediaType.Json) - { - var httpClient = httpClientFactory.CreateClient(); - HttpResponseMessage? response = null; - if (mediaType == PostRequestMediaType.Json) - { - var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); - response = await httpClient.PostAsync(url, content); - } - else if (mediaType == PostRequestMediaType.FormUrlEncoded) - { - var content = new FormUrlEncodedContent(ObjectToKeyValuePairs(data)); - response = await httpClient.PostAsync(url, content); - } - - response?.EnsureSuccessStatusCode(); - var responseContent = await response!.Content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(responseContent) ?? - throw new HttpRequestException( - $"Error deserializing data from the server. Status code: {response.StatusCode}"); - } - - private static Dictionary ObjectToKeyValuePairs(object obj) - { - var keyValuePairs = new Dictionary(); - foreach (PropertyInfo property in obj.GetType().GetProperties()) - { - var value = property.GetValue(obj)?.ToString(); - if (value != null) - { - keyValuePairs.Add(property.Name, value); - } - } - - return keyValuePairs; - } -} +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Application.Interfaces.Services; +using Domain.Common.Enums; + +namespace Application.Services; + +public class HttpRequestService(IHttpClientFactory httpClientFactory) : IHttpRequestService +{ + public async Task GetAsync(string url, object? data = null, string? token = null) + { + var httpClient = httpClientFactory.CreateClient(); + if (!string.IsNullOrEmpty(token)) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + if (data is not null) + { + var query = string.Join("&", data.GetType().GetProperties() + .Select(x => + $"{Uri.EscapeDataString(x.Name)}={Uri.EscapeDataString(x.GetValue(data)?.ToString() ?? string.Empty)}")); + url += $"?{query}"; + } + + var response = await httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content) ?? + throw new HttpRequestException( + $"Error deserializing data from the server. Status code: {response.StatusCode}"); + } + + public async Task PostAsync(string url, object data, + PostRequestMediaType mediaType = PostRequestMediaType.Json) + { + var httpClient = httpClientFactory.CreateClient(); + HttpResponseMessage? response = null; + if (mediaType == PostRequestMediaType.Json) + { + var content = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json"); + response = await httpClient.PostAsync(url, content); + } + else if (mediaType == PostRequestMediaType.FormUrlEncoded) + { + var content = new FormUrlEncodedContent(ObjectToKeyValuePairs(data)); + response = await httpClient.PostAsync(url, content); + } + + response?.EnsureSuccessStatusCode(); + var responseContent = await response!.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseContent) ?? + throw new HttpRequestException( + $"Error deserializing data from the server. Status code: {response.StatusCode}"); + } + + private static Dictionary ObjectToKeyValuePairs(object obj) + { + var keyValuePairs = new Dictionary(); + foreach (PropertyInfo property in obj.GetType().GetProperties()) + { + var value = property.GetValue(obj)?.ToString(); + if (value != null) + { + keyValuePairs.Add(property.Name, value); + } + } + + return keyValuePairs; + } +} diff --git a/src/Application/Services/JokeService.cs b/src/Application/Services/JokeService.cs index 0ae6208..b39a256 100644 --- a/src/Application/Services/JokeService.cs +++ b/src/Application/Services/JokeService.cs @@ -1,19 +1,19 @@ -using Application.Configs; -using Application.DTOs; -using Application.Interfaces.Services; -using Microsoft.Extensions.Configuration; - -namespace Application.Services; - - public class JokeService(IHttpRequestService httpRequestService, IConfiguration configuration) - : IRandomService - { - private readonly JokeQuoteSettingDto _jokeConfig = configuration.GetConfiguration("JokeSettings")!; - - public async Task GetAsync() - { - var joke = await httpRequestService.GetAsync(_jokeConfig.ApiUrl); - return $"{_jokeConfig.Greeting} {joke.Setup} {joke.Delivery}"; - } - } - +using Application.Configs; +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.Configuration; + +namespace Application.Services; + + public class JokeService(IHttpRequestService httpRequestService, IConfiguration configuration) + : IRandomService + { + private readonly JokeQuoteSettingDto _jokeConfig = configuration.GetConfiguration("JokeSettings")!; + + public async Task GetAsync() + { + var joke = await httpRequestService.GetAsync(_jokeConfig.ApiUrl); + return $"{_jokeConfig.Greeting} {joke.Setup} {joke.Delivery}"; + } + } + diff --git a/src/Application/Services/QuoteService.cs b/src/Application/Services/QuoteService.cs index efd05ca..66d88f1 100644 --- a/src/Application/Services/QuoteService.cs +++ b/src/Application/Services/QuoteService.cs @@ -1,17 +1,17 @@ -using Application.Configs; -using Application.DTOs; -using Application.Interfaces.Services; -using Microsoft.Extensions.Configuration; - -namespace Application.Services; - -public class QuoteService(IHttpRequestService httpRequestService, IConfiguration configuration) : IRandomService -{ - private readonly JokeQuoteSettingDto _quoteConfig = configuration.GetConfiguration("QuoteSettings")!; - - public async Task GetAsync() - { - var quote = await httpRequestService.GetAsync(_quoteConfig.ApiUrl); - return $"{_quoteConfig.Greeting} {quote.Content} by {quote.Author}"; - } -} +using Application.Configs; +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.Configuration; + +namespace Application.Services; + +public class QuoteService(IHttpRequestService httpRequestService, IConfiguration configuration) : IRandomService +{ + private readonly JokeQuoteSettingDto _quoteConfig = configuration.GetConfiguration("QuoteSettings")!; + + public async Task GetAsync() + { + var quote = await httpRequestService.GetAsync(_quoteConfig.ApiUrl); + return $"{_quoteConfig.Greeting} {quote.Content} by {quote.Author}"; + } +} diff --git a/src/Application/Services/SpotifyService.cs b/src/Application/Services/SpotifyService.cs index a37f883..b991b94 100644 --- a/src/Application/Services/SpotifyService.cs +++ b/src/Application/Services/SpotifyService.cs @@ -1,112 +1,112 @@ -using System.Text; -using Application.Configs; -using Application.DTOs.Spotify; -using Application.Interfaces.Services; -using Application.Store; -using Domain.Common.Enums; -using Microsoft.Extensions.Configuration; - -namespace Application.Services; - -public class SpotifyService(IHttpRequestService httpRequestService, GlobalStore globalStore, IConfiguration configuration) : ISpotifyService -{ - private readonly GlobalStore _globalStore = globalStore ?? throw new ArgumentNullException(nameof(globalStore)); - private readonly string? _spotifyClientId = configuration.GetConfiguration("SpotifySettings:ClientId"); - private readonly string? _spotifySecret = configuration.GetConfiguration("SpotifySettings:ClientSecret"); - private const string SpotifyBaseUrl = "https://api.spotify.com"; - - // top 5 genres - private static readonly string[] LaguIbanGenre = ["lagu iban"]; - - private SearchDto _tracks = new(); - private ArtistDto _artistDto = new(); - - public async Task GetRecommendationAsync(string songTitle) - { - Console.WriteLine("GetRecommendationAsync " + songTitle); - await CheckAuth(); - await SearchTrackAsync(songTitle); - await GetArtistAsync(); - - var artistId = _tracks.Tracks.Items.FirstOrDefault()?.Artists.FirstOrDefault()?.Id; - var trackId = _tracks.Tracks.Items.FirstOrDefault()?.Id; - var genre = _artistDto.Genres; - - const string url = $"{SpotifyBaseUrl}/v1/recommendations"; - // remove from the list if the genre contains "-" - genre = genre.Where(x => !x.Contains('-')).ToArray(); - var data = new - { - limit = 10, - market = "MY", - seed_artists = artistId, - seed_genres = new StringBuilder().AppendJoin(",", genre.Length > 0 ? genre : LaguIbanGenre).ToString(), - seed_tracks = trackId - }; - var response = - await httpRequestService.GetAsync(url, data, _globalStore.Get()!.AccessToken); - - _globalStore.Set(response.Tracks); - - - } - - #region private methods - - private async Task SearchTrackAsync(string songTitle) - { - await CheckAuth(); - - const string url = $"{SpotifyBaseUrl}/v1/search"; - var data = new - { - q = songTitle, - type = "track", - market = "MY", - limit = 1 - }; - _tracks = await httpRequestService.GetAsync(url, data, _globalStore.Get()!.AccessToken); - } - - private async Task GetArtistAsync() - { - await CheckAuth(); - - var artistId = _tracks.Tracks.Items.FirstOrDefault()?.Artists.FirstOrDefault()?.Id; - - var url = $"{SpotifyBaseUrl}/v1/artists/{artistId}"; - _artistDto = await httpRequestService.GetAsync(url, null, _globalStore.Get()!.AccessToken); - } - - - private async Task GetAuthAsync() - { - const string url = $"https://accounts.spotify.com/api/token"; - var data = new - { - grant_type = "client_credentials", - client_id = _spotifyClientId, - client_secret = _spotifySecret - }; - var response = await httpRequestService.PostAsync(url, data, PostRequestMediaType.FormUrlEncoded); - return response; - } - - private async Task CheckAuth() - { - if (!_globalStore.TryGet(out _)) - { - _globalStore.Set(await GetAuthAsync()); - } - else - { - var auth = _globalStore.Get(); - if (auth?.TimeStamp.AddSeconds(auth.ExpiresIn) < DateTimeOffset.UtcNow) - { - _globalStore.Set(await GetAuthAsync()); - } - } - } - - #endregion +using System.Text; +using Application.Configs; +using Application.DTOs.Spotify; +using Application.Interfaces.Services; +using Application.Store; +using Domain.Common.Enums; +using Microsoft.Extensions.Configuration; + +namespace Application.Services; + +public class SpotifyService(IHttpRequestService httpRequestService, GlobalStore globalStore, IConfiguration configuration) : ISpotifyService +{ + private readonly GlobalStore _globalStore = globalStore ?? throw new ArgumentNullException(nameof(globalStore)); + private readonly string? _spotifyClientId = configuration.GetConfiguration("SpotifySettings:ClientId"); + private readonly string? _spotifySecret = configuration.GetConfiguration("SpotifySettings:ClientSecret"); + private const string SpotifyBaseUrl = "https://api.spotify.com"; + + // top 5 genres + private static readonly string[] LaguIbanGenre = ["lagu iban"]; + + private SearchDto _tracks = new(); + private ArtistDto _artistDto = new(); + + public async Task GetRecommendationAsync(string songTitle) + { + Console.WriteLine("GetRecommendationAsync " + songTitle); + await CheckAuth(); + await SearchTrackAsync(songTitle); + await GetArtistAsync(); + + var artistId = _tracks.Tracks.Items.FirstOrDefault()?.Artists.FirstOrDefault()?.Id; + var trackId = _tracks.Tracks.Items.FirstOrDefault()?.Id; + var genre = _artistDto.Genres; + + const string url = $"{SpotifyBaseUrl}/v1/recommendations"; + // remove from the list if the genre contains "-" + genre = genre.Where(x => !x.Contains('-')).ToArray(); + var data = new + { + limit = 10, + market = "MY", + seed_artists = artistId, + seed_genres = new StringBuilder().AppendJoin(",", genre.Length > 0 ? genre : LaguIbanGenre).ToString(), + seed_tracks = trackId + }; + var response = + await httpRequestService.GetAsync(url, data, _globalStore.Get()!.AccessToken); + + _globalStore.Set(response.Tracks); + + + } + + #region private methods + + private async Task SearchTrackAsync(string songTitle) + { + await CheckAuth(); + + const string url = $"{SpotifyBaseUrl}/v1/search"; + var data = new + { + q = songTitle, + type = "track", + market = "MY", + limit = 1 + }; + _tracks = await httpRequestService.GetAsync(url, data, _globalStore.Get()!.AccessToken); + } + + private async Task GetArtistAsync() + { + await CheckAuth(); + + var artistId = _tracks.Tracks.Items.FirstOrDefault()?.Artists.FirstOrDefault()?.Id; + + var url = $"{SpotifyBaseUrl}/v1/artists/{artistId}"; + _artistDto = await httpRequestService.GetAsync(url, null, _globalStore.Get()!.AccessToken); + } + + + private async Task GetAuthAsync() + { + const string url = $"https://accounts.spotify.com/api/token"; + var data = new + { + grant_type = "client_credentials", + client_id = _spotifyClientId, + client_secret = _spotifySecret + }; + var response = await httpRequestService.PostAsync(url, data, PostRequestMediaType.FormUrlEncoded); + return response; + } + + private async Task CheckAuth() + { + if (!_globalStore.TryGet(out _)) + { + _globalStore.Set(await GetAuthAsync()); + } + else + { + var auth = _globalStore.Get(); + if (auth?.TimeStamp.AddSeconds(auth.ExpiresIn) < DateTimeOffset.UtcNow) + { + _globalStore.Set(await GetAuthAsync()); + } + } + } + + #endregion } \ No newline at end of file diff --git a/src/Application/Store/GlobalStore.cs b/src/Application/Store/GlobalStore.cs index 2729318..4f7472b 100644 --- a/src/Application/Store/GlobalStore.cs +++ b/src/Application/Store/GlobalStore.cs @@ -1,66 +1,66 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; - -namespace Application.Store; - -/// -/// Since this is just a simple bot for single server -/// we can use a memory store to store the necessary data. -/// -public class GlobalStore -{ - private readonly ConcurrentDictionary _store = new(); - - /// - /// Set the value of the item in the store of type - /// If the item is already set, it will be overwritten - /// - /// - /// - /// - public void Set(T item) - { - ArgumentNullException.ThrowIfNull(item, nameof(item)); - - _store[typeof(T)] = item; - } - - /// - /// Get the value of the item in the store of type - /// - /// - /// - public T? Get() - { - return _store.TryGetValue(typeof(T), out var value) ? (T)value : default; - } - - /// - /// Try to get the value of the item in the store of type - /// - /// - /// - /// - public bool TryGet([NotNullWhen(true)] out T? item) - { - if (_store.TryGetValue(typeof(T), out var value)) - { - item = (T)value; - return true; - } - - item = default; - return false; - } - - /// - /// Try to remove the item from the store of type - /// Use this method with caution as it will remove the item key from the store - /// If the item key is removed, there will be no way to get the item back - /// - /// - public void Clear() - { - _store.TryRemove(typeof(T), out _); - } -} +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace Application.Store; + +/// +/// Since this is just a simple bot for single server +/// we can use a memory store to store the necessary data. +/// +public class GlobalStore +{ + private readonly ConcurrentDictionary _store = new(); + + /// + /// Set the value of the item in the store of type + /// If the item is already set, it will be overwritten + /// + /// + /// + /// + public void Set(T item) + { + ArgumentNullException.ThrowIfNull(item, nameof(item)); + + _store[typeof(T)] = item; + } + + /// + /// Get the value of the item in the store of type + /// + /// + /// + public T? Get() + { + return _store.TryGetValue(typeof(T), out var value) ? (T)value : default; + } + + /// + /// Try to get the value of the item in the store of type + /// + /// + /// + /// + public bool TryGet([NotNullWhen(true)] out T? item) + { + if (_store.TryGetValue(typeof(T), out var value)) + { + item = (T)value; + return true; + } + + item = default; + return false; + } + + /// + /// Try to remove the item from the store of type + /// Use this method with caution as it will remove the item key from the store + /// If the item key is removed, there will be no way to get the item back + /// + /// + public void Clear() + { + _store.TryRemove(typeof(T), out _); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 54b292d..c0703aa 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - - - net10.0 - enable - enable - + + + net10.0 + enable + enable + \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index eb7f834..ba04e4e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,35 +1,35 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Domain/Common/Constants.cs b/src/Domain/Common/Constants.cs index be6a5bb..9aab87c 100644 --- a/src/Domain/Common/Constants.cs +++ b/src/Domain/Common/Constants.cs @@ -1,30 +1,30 @@ -using Domain.Events; - -namespace Domain.Common; - -public static class Constants -{ - public static class CustomIds - { - public const string Play = nameof(EventType.Play); - public const string PlayListPlay = nameof(EventType.PlayListPlay); - public const string Skip = nameof(EventType.Skip); - public const string Stop = nameof(EventType.Stop); - } -} - -public class EventType -{ - public record Play : IEvent; - public record PlayListPlay : IEvent; - public record Stop(ulong GuildId) : IEvent; - public record Skip(ulong GuildId) : IEvent; -} - -public enum AudioSource -{ - Youtube, - SoundCloud, - Url, - Radio +using Domain.Events; + +namespace Domain.Common; + +public static class Constants +{ + public static class CustomIds + { + public const string Play = nameof(EventType.Play); + public const string PlayListPlay = nameof(EventType.PlayListPlay); + public const string Skip = nameof(EventType.Skip); + public const string Stop = nameof(EventType.Stop); + } +} + +public class EventType +{ + public record Play : IEvent; + public record PlayListPlay : IEvent; + public record Stop(ulong GuildId) : IEvent; + public record Skip(ulong GuildId) : IEvent; +} + +public enum AudioSource +{ + Youtube, + SoundCloud, + Url, + Radio } \ No newline at end of file diff --git a/src/Domain/Common/EntityBase.cs b/src/Domain/Common/EntityBase.cs index 6fb42e7..b241f74 100644 --- a/src/Domain/Common/EntityBase.cs +++ b/src/Domain/Common/EntityBase.cs @@ -1,7 +1,7 @@ -namespace Domain.Common; - -public class EntityBase -{ - public DateTimeOffset CreatedAt { get; private set; } = DateTimeOffset.UtcNow; - public DateTimeOffset? UpdatedAt { get; set; } +namespace Domain.Common; + +public class EntityBase +{ + public DateTimeOffset CreatedAt { get; private set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? UpdatedAt { get; set; } } \ No newline at end of file diff --git a/src/Domain/Common/Enums/GlobalEnum.cs b/src/Domain/Common/Enums/GlobalEnum.cs index 66b8ad7..de1c583 100644 --- a/src/Domain/Common/Enums/GlobalEnum.cs +++ b/src/Domain/Common/Enums/GlobalEnum.cs @@ -1,17 +1,17 @@ -using System.ComponentModel; - -namespace Domain.Common.Enums; - -public enum YtSearchCollection -{ - FirstFive, - Random -} - -public enum PostRequestMediaType -{ - [Description("application/json")] - Json, - [Description("application/x-www-form-urlencoded")] - FormUrlEncoded -} +using System.ComponentModel; + +namespace Domain.Common.Enums; + +public enum YtSearchCollection +{ + FirstFive, + Random +} + +public enum PostRequestMediaType +{ + [Description("application/json")] + Json, + [Description("application/x-www-form-urlencoded")] + FormUrlEncoded +} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index 2ef1a36..2ae79d6 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -1 +1 @@ - + diff --git a/src/Domain/Entities/PlayHistory.cs b/src/Domain/Entities/PlayHistory.cs index d06a3b1..1ed836c 100644 --- a/src/Domain/Entities/PlayHistory.cs +++ b/src/Domain/Entities/PlayHistory.cs @@ -1,34 +1,34 @@ -using Domain.Common; - -namespace Domain.Entities; - -public class PlayHistory(DateTimeOffset playedAt, ulong userId, Guid songId) - : EntityBase -{ - public Guid Id { get; init; } - - public DateTimeOffset PlayedAt { get; set; } = playedAt; - - public ulong UserId { get; init; } = userId; - public User User { get; init; } = null!; - - public Guid SongId { get; init; } = songId; - public Song Song { get; init; } = null!; - - public int TotalPlays { get; private set; } = 1; - - public static PlayHistory Create(DateTimeOffset playedAt, ulong userId, Guid songId) - { - return new PlayHistory(playedAt, userId, songId); - } - - public static PlayHistory UpdateTotalPlays(PlayHistory playHistory) - { - ArgumentNullException.ThrowIfNull(playHistory); - - playHistory.PlayedAt = DateTimeOffset.UtcNow; - playHistory.UpdatedAt = DateTimeOffset.UtcNow; - playHistory.TotalPlays += 1; - return playHistory; - } +using Domain.Common; + +namespace Domain.Entities; + +public class PlayHistory(DateTimeOffset playedAt, ulong userId, Guid songId) + : EntityBase +{ + public Guid Id { get; init; } + + public DateTimeOffset PlayedAt { get; set; } = playedAt; + + public ulong UserId { get; init; } = userId; + public User User { get; init; } = null!; + + public Guid SongId { get; init; } = songId; + public Song Song { get; init; } = null!; + + public int TotalPlays { get; private set; } = 1; + + public static PlayHistory Create(DateTimeOffset playedAt, ulong userId, Guid songId) + { + return new PlayHistory(playedAt, userId, songId); + } + + public static PlayHistory UpdateTotalPlays(PlayHistory playHistory) + { + ArgumentNullException.ThrowIfNull(playHistory); + + playHistory.PlayedAt = DateTimeOffset.UtcNow; + playHistory.UpdatedAt = DateTimeOffset.UtcNow; + playHistory.TotalPlays += 1; + return playHistory; + } } \ No newline at end of file diff --git a/src/Domain/Entities/RadioSource.cs b/src/Domain/Entities/RadioSource.cs index 7be6eb7..5b4d648 100644 --- a/src/Domain/Entities/RadioSource.cs +++ b/src/Domain/Entities/RadioSource.cs @@ -1,51 +1,51 @@ -using Domain.Common; - -namespace Domain.Entities; - -public class RadioSource: EntityBase -{ - public Guid Id { get; init; } - public string Name { get; set; } - public string SourceUrl { get; private set; } - public bool IsActive { get; set; } = true; - - private RadioSource(string name, string sourceUrl) - { - Name = name; - SourceUrl = sourceUrl; - } - - public static void Update(RadioSource radioSource, string name, string newSourceUrl, bool isActive) - { - ArgumentNullException.ThrowIfNull(radioSource); - ArgumentNullException.ThrowIfNull(radioSource); - ArgumentNullException.ThrowIfNull(newSourceUrl); - - radioSource.Name = name; - radioSource.SourceUrl = newSourceUrl; - radioSource.IsActive = isActive; - } - - public static RadioSource Create(string name, string sourceUrl) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Name cannot be null or empty.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(sourceUrl)) - { - throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); - } - - return new RadioSource(name, sourceUrl); - } - - public static RadioSource UpdateIsActive(RadioSource radioSource, bool isActive) - { - ArgumentNullException.ThrowIfNull(radioSource, nameof(radioSource)); - - radioSource.IsActive = isActive; - return radioSource; - } +using Domain.Common; + +namespace Domain.Entities; + +public class RadioSource: EntityBase +{ + public Guid Id { get; init; } + public string Name { get; set; } + public string SourceUrl { get; private set; } + public bool IsActive { get; set; } = true; + + private RadioSource(string name, string sourceUrl) + { + Name = name; + SourceUrl = sourceUrl; + } + + public static void Update(RadioSource radioSource, string name, string newSourceUrl, bool isActive) + { + ArgumentNullException.ThrowIfNull(radioSource); + ArgumentNullException.ThrowIfNull(radioSource); + ArgumentNullException.ThrowIfNull(newSourceUrl); + + radioSource.Name = name; + radioSource.SourceUrl = newSourceUrl; + radioSource.IsActive = isActive; + } + + public static RadioSource Create(string name, string sourceUrl) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Name cannot be null or empty.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(sourceUrl)) + { + throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); + } + + return new RadioSource(name, sourceUrl); + } + + public static RadioSource UpdateIsActive(RadioSource radioSource, bool isActive) + { + ArgumentNullException.ThrowIfNull(radioSource, nameof(radioSource)); + + radioSource.IsActive = isActive; + return radioSource; + } } \ No newline at end of file diff --git a/src/Domain/Entities/Song.cs b/src/Domain/Entities/Song.cs index 244f2e8..28cb646 100644 --- a/src/Domain/Entities/Song.cs +++ b/src/Domain/Entities/Song.cs @@ -1,33 +1,33 @@ -using System.Text.Json.Serialization; -using Domain.Common; - -namespace Domain.Entities; - -public class Song: EntityBase -{ - public Guid Id { get; init; } - public string SourceUrl { get; init; } - public string Title { get; init; } - public bool IsBlacklisted { get; private set; } = false; - - public ICollection PlayHistories { get; set; } = new List(); - - private Song(string sourceUrl, string title) - { - SourceUrl = sourceUrl; - Title = title; - } - - public static Song Create(string sourceUrl, string title) - { - return new Song(sourceUrl, title); - } - - public static Song MarkAsBlacklisted(Song song, bool isBlacklisted) - { - ArgumentNullException.ThrowIfNull(song, nameof(song)); - - song.IsBlacklisted = isBlacklisted; - return song; - } +using System.Text.Json.Serialization; +using Domain.Common; + +namespace Domain.Entities; + +public class Song: EntityBase +{ + public Guid Id { get; init; } + public string SourceUrl { get; init; } + public string Title { get; init; } + public bool IsBlacklisted { get; private set; } = false; + + public ICollection PlayHistories { get; set; } = new List(); + + private Song(string sourceUrl, string title) + { + SourceUrl = sourceUrl; + Title = title; + } + + public static Song Create(string sourceUrl, string title) + { + return new Song(sourceUrl, title); + } + + public static Song MarkAsBlacklisted(Song song, bool isBlacklisted) + { + ArgumentNullException.ThrowIfNull(song, nameof(song)); + + song.IsBlacklisted = isBlacklisted; + return song; + } } \ No newline at end of file diff --git a/src/Domain/Entities/User.cs b/src/Domain/Entities/User.cs index 6c3c8ac..697a428 100644 --- a/src/Domain/Entities/User.cs +++ b/src/Domain/Entities/User.cs @@ -1,40 +1,40 @@ -using System.Text.Json.Serialization; -using Domain.Common; - -namespace Domain.Entities; - -public class User: EntityBase -{ - public ulong Id { get; init; } - public string Username { get; init; } - public string? DisplayName { get; init; } - public int TotalSongsPlayed { get; set; } - - public ICollection PlayHistories { get; set; } = new List(); - - private User(ulong id, string username, string displayName) - { - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("Username cannot be null or empty.", nameof(username)); - } - - Id = id; - DisplayName = displayName; - Username = username; - TotalSongsPlayed = 0; - } - - public static User Create(ulong userId, string username, string displayName) - { - return new User(userId, username, displayName); - } - - public static User UpdateTotalSongsPlayed(User user) - { - ArgumentNullException.ThrowIfNull(user); - - user.TotalSongsPlayed += 1; - return user; - } +using System.Text.Json.Serialization; +using Domain.Common; + +namespace Domain.Entities; + +public class User: EntityBase +{ + public ulong Id { get; init; } + public string Username { get; init; } + public string? DisplayName { get; init; } + public int TotalSongsPlayed { get; set; } + + public ICollection PlayHistories { get; set; } = new List(); + + private User(ulong id, string username, string displayName) + { + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("Username cannot be null or empty.", nameof(username)); + } + + Id = id; + DisplayName = displayName; + Username = username; + TotalSongsPlayed = 0; + } + + public static User Create(ulong userId, string username, string displayName) + { + return new User(userId, username, displayName); + } + + public static User UpdateTotalSongsPlayed(User user) + { + ArgumentNullException.ThrowIfNull(user); + + user.TotalSongsPlayed += 1; + return user; + } } \ No newline at end of file diff --git a/src/Domain/Eventing/IAsyncEventDispatcher.cs b/src/Domain/Eventing/IAsyncEventDispatcher.cs index 217cd11..affba90 100644 --- a/src/Domain/Eventing/IAsyncEventDispatcher.cs +++ b/src/Domain/Eventing/IAsyncEventDispatcher.cs @@ -1,9 +1,9 @@ -using Domain.Events; - -namespace Domain.Eventing; - -public interface IAsyncEventDispatcher -{ - Task DispatchAsync(TEvent @event, CancellationToken ct = default) - where TEvent : IEvent; +using Domain.Events; + +namespace Domain.Eventing; + +public interface IAsyncEventDispatcher +{ + Task DispatchAsync(TEvent @event, CancellationToken ct = default) + where TEvent : IEvent; } \ No newline at end of file diff --git a/src/Domain/Eventing/IAsyncEventHandler.cs b/src/Domain/Eventing/IAsyncEventHandler.cs index 0205124..50e2e17 100644 --- a/src/Domain/Eventing/IAsyncEventHandler.cs +++ b/src/Domain/Eventing/IAsyncEventHandler.cs @@ -1,8 +1,8 @@ -using Domain.Events; - -namespace Domain.Eventing; - -public interface IAsyncEventHandler where TEvent : IEvent -{ - Task HandleAsync(TEvent @event, CancellationToken ct = default); +using Domain.Events; + +namespace Domain.Eventing; + +public interface IAsyncEventHandler where TEvent : IEvent +{ + Task HandleAsync(TEvent @event, CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Domain/Eventing/IEventDispatcher.cs b/src/Domain/Eventing/IEventDispatcher.cs index bae60ac..3f50316 100644 --- a/src/Domain/Eventing/IEventDispatcher.cs +++ b/src/Domain/Eventing/IEventDispatcher.cs @@ -1,8 +1,8 @@ -using Domain.Events; - -namespace Domain.Eventing; - -public interface IEventDispatcher -{ - void Dispatch(TEvent @event) where TEvent : IEvent; +using Domain.Events; + +namespace Domain.Eventing; + +public interface IEventDispatcher +{ + void Dispatch(TEvent @event) where TEvent : IEvent; } \ No newline at end of file diff --git a/src/Domain/Eventing/IEventHandler.cs b/src/Domain/Eventing/IEventHandler.cs index 49ef892..eb4708c 100644 --- a/src/Domain/Eventing/IEventHandler.cs +++ b/src/Domain/Eventing/IEventHandler.cs @@ -1,8 +1,8 @@ -using Domain.Events; - -namespace Domain.Eventing; - -public interface IEventHandler where TEvent : IEvent -{ - void Handle(TEvent @event); +using Domain.Events; + +namespace Domain.Eventing; + +public interface IEventHandler where TEvent : IEvent +{ + void Handle(TEvent @event); } \ No newline at end of file diff --git a/src/Domain/Events/IEvent.cs b/src/Domain/Events/IEvent.cs index 2c60b46..d20e8ae 100644 --- a/src/Domain/Events/IEvent.cs +++ b/src/Domain/Events/IEvent.cs @@ -1,3 +1,3 @@ -namespace Domain.Events; - +namespace Domain.Events; + public interface IEvent; \ No newline at end of file diff --git a/src/Infrastructure/Commands/AdminCommands.cs b/src/Infrastructure/Commands/AdminCommands.cs index 69cf5f1..7a44480 100644 --- a/src/Infrastructure/Commands/AdminCommands.cs +++ b/src/Infrastructure/Commands/AdminCommands.cs @@ -1,107 +1,107 @@ -using Application.Interfaces.Services; -using Domain.Common; -using Domain.Eventing; -using Infrastructure.Services; -using Microsoft.Extensions.DependencyInjection; -using NetCord; -using NetCord.Rest; -using NetCord.Services; -using NetCord.Services.ApplicationCommands; -using NetCord.Services.Commands; -using NetCord.Services.ComponentInteractions; - -namespace Infrastructure.Commands; - -[SlashCommand("action", "Blacklist a song from being played")] -[RequireUserPermissions(Permissions.Administrator)] -public class AdminCommands( - [FromKeyedServices(nameof(YoutubeService))] - IStreamService youtubeService, - IGuildMusicService guildMusicService, - IServiceProvider serviceProvider) : ApplicationCommandModule -{ - [SubSlashCommand("blacklist", "Blacklist the currently playing song")] - public async Task BlacklistAsync() - { - if (Context.Guild is null) - { - await RespondAsync(InteractionCallback.Message("This command can only be used in a server.")); - return; - } - - var song = guildMusicService.GetNowPlaying(Context.Guild.Id); - if (song is null) - { - await RespondAsync(InteractionCallback.Message( - "There is no song to blacklist. Please use the /play command to search for a song first.")); - return; - } - - var url = song.VideoUrl ?? (song.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]; - if (url is null) - { - await RespondAsync(InteractionCallback.Message("No song was selected to blacklist.")); - return; - } - - if (Guid.TryParse(url, out _)) - { - await RespondAsync(InteractionCallback.Message("Radio stations cannot be blacklisted.")); - return; - } - - var title = await youtubeService.GetVideoTitleAsync(url, CancellationToken.None); - - using var scope = serviceProvider.CreateScope(); - var blacklistService = scope.ServiceProvider.GetRequiredService(); - - var added = await blacklistService.AddToBlacklistAsync(url); - if (!added) - { - await RespondAsync(InteractionCallback.Message( - $"The song with title '{title}' was not found in the library and could not be blacklisted.")); - return; - } - - await RespondAsync(InteractionCallback.Message($"The song with title '{title}' has been blacklisted.")); - - // Skip the blacklisted track; the player disconnects on its own when the queue is empty. - var eventDispatcher = scope.ServiceProvider.GetRequiredService(); - eventDispatcher.Dispatch(new EventType.Skip(Context.Guild.Id)); - } - - [SubSlashCommand("unblacklist", "Remove a song from the blacklist")] - public async Task UnblacklistAsync([CommandParameter(Remainder = true, Name = "song title")] string title) - { - using var scope = serviceProvider.CreateScope(); - var blacklistService = scope.ServiceProvider.GetRequiredService(); - - var removed = await blacklistService.RemoveFromBlacklistAsync(title); - - var message = - CommandUtils.CreateMessage(removed - ? $"The song with title '{title}' has been removed from the blacklist." - : $"No blacklisted song matching '{title}' was found."); - await RespondAsync(InteractionCallback.Message(message)); - } - - [SubSlashCommand("list", "List all blacklisted songs")] - public async Task ListBlacklistedSongsAsync() - { - using var scope = serviceProvider.CreateScope(); - var blacklistService = scope.ServiceProvider.GetRequiredService(); - - var blacklistedSongs = await blacklistService.GetBlacklistedSongsAsync(); - - if (blacklistedSongs.Count == 0) - { - await RespondAsync(InteractionCallback.Message("There are no blacklisted songs.")); - return; - } - - var message = string.Join(Environment.NewLine, - blacklistedSongs.Select((song, index) => $"{index + 1}. {song.Title}")); - - await RespondAsync(InteractionCallback.Message(message)); - } -} +using Application.Interfaces.Services; +using Domain.Common; +using Domain.Eventing; +using Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using NetCord; +using NetCord.Rest; +using NetCord.Services; +using NetCord.Services.ApplicationCommands; +using NetCord.Services.Commands; +using NetCord.Services.ComponentInteractions; + +namespace Infrastructure.Commands; + +[SlashCommand("action", "Blacklist a song from being played")] +[RequireUserPermissions(Permissions.Administrator)] +public class AdminCommands( + [FromKeyedServices(nameof(YoutubeService))] + IStreamService youtubeService, + IGuildMusicService guildMusicService, + IServiceProvider serviceProvider) : ApplicationCommandModule +{ + [SubSlashCommand("blacklist", "Blacklist the currently playing song")] + public async Task BlacklistAsync() + { + if (Context.Guild is null) + { + await RespondAsync(InteractionCallback.Message("This command can only be used in a server.")); + return; + } + + var song = guildMusicService.GetNowPlaying(Context.Guild.Id); + if (song is null) + { + await RespondAsync(InteractionCallback.Message( + "There is no song to blacklist. Please use the /play command to search for a song first.")); + return; + } + + var url = song.VideoUrl ?? (song.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]; + if (url is null) + { + await RespondAsync(InteractionCallback.Message("No song was selected to blacklist.")); + return; + } + + if (Guid.TryParse(url, out _)) + { + await RespondAsync(InteractionCallback.Message("Radio stations cannot be blacklisted.")); + return; + } + + var title = await youtubeService.GetVideoTitleAsync(url, CancellationToken.None); + + using var scope = serviceProvider.CreateScope(); + var blacklistService = scope.ServiceProvider.GetRequiredService(); + + var added = await blacklistService.AddToBlacklistAsync(url); + if (!added) + { + await RespondAsync(InteractionCallback.Message( + $"The song with title '{title}' was not found in the library and could not be blacklisted.")); + return; + } + + await RespondAsync(InteractionCallback.Message($"The song with title '{title}' has been blacklisted.")); + + // Skip the blacklisted track; the player disconnects on its own when the queue is empty. + var eventDispatcher = scope.ServiceProvider.GetRequiredService(); + eventDispatcher.Dispatch(new EventType.Skip(Context.Guild.Id)); + } + + [SubSlashCommand("unblacklist", "Remove a song from the blacklist")] + public async Task UnblacklistAsync([CommandParameter(Remainder = true, Name = "song title")] string title) + { + using var scope = serviceProvider.CreateScope(); + var blacklistService = scope.ServiceProvider.GetRequiredService(); + + var removed = await blacklistService.RemoveFromBlacklistAsync(title); + + var message = + CommandUtils.CreateMessage(removed + ? $"The song with title '{title}' has been removed from the blacklist." + : $"No blacklisted song matching '{title}' was found."); + await RespondAsync(InteractionCallback.Message(message)); + } + + [SubSlashCommand("list", "List all blacklisted songs")] + public async Task ListBlacklistedSongsAsync() + { + using var scope = serviceProvider.CreateScope(); + var blacklistService = scope.ServiceProvider.GetRequiredService(); + + var blacklistedSongs = await blacklistService.GetBlacklistedSongsAsync(); + + if (blacklistedSongs.Count == 0) + { + await RespondAsync(InteractionCallback.Message("There are no blacklisted songs.")); + return; + } + + var message = string.Join(Environment.NewLine, + blacklistedSongs.Select((song, index) => $"{index + 1}. {song.Title}")); + + await RespondAsync(InteractionCallback.Message(message)); + } +} diff --git a/src/Infrastructure/Commands/CommandUtils.cs b/src/Infrastructure/Commands/CommandUtils.cs index 4d617b9..01e37f4 100644 --- a/src/Infrastructure/Commands/CommandUtils.cs +++ b/src/Infrastructure/Commands/CommandUtils.cs @@ -1,60 +1,60 @@ -using Domain.Common; -using Domain.Eventing; -using Domain.Events; -using Microsoft.Extensions.DependencyInjection; -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -namespace Infrastructure.Commands; - -public static class CommandUtils -{ - internal static T CreateMessage(string message) where T : IMessageProperties, new() - { - return new() - { - Content = message, - Components = [], - }; - } - - internal static IEnumerable CreateComponent(T source, string id = Constants.CustomIds.Play) - where T : IEnumerable - { - return - [ - new StringMenuProperties(id) - { - Options = source.Select(s => new StringMenuSelectOptionProperties(s.Title, s.Url) - { - Description = s.Description ?? string.Empty, - }).ToList() - } - ]; - } - - internal static async Task NotInVoiceChannel(ApplicationCommandContext context, Func, Task> respondAsync) - { - if (context.Guild is null) - { - var notInGuildMessage = - CreateMessage("This command can only be used in a server."); - await respondAsync(InteractionCallback.Message(notInGuildMessage)); - return true; - } - - if (!context.Guild.VoiceStates.TryGetValue(context.User.Id, out _)) - { - var notInVoiceChannelMessage = - CreateMessage("You must be in a voice channel to use this command."); - await respondAsync(InteractionCallback.Message(notInVoiceChannelMessage)); - return true; - } - - return false; - } - - internal record ComponentModel(string Title, string Url, string? Description = null); - - +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Microsoft.Extensions.DependencyInjection; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +namespace Infrastructure.Commands; + +public static class CommandUtils +{ + internal static T CreateMessage(string message) where T : IMessageProperties, new() + { + return new() + { + Content = message, + Components = [], + }; + } + + internal static IEnumerable CreateComponent(T source, string id = Constants.CustomIds.Play) + where T : IEnumerable + { + return + [ + new StringMenuProperties(id) + { + Options = source.Select(s => new StringMenuSelectOptionProperties(s.Title, s.Url) + { + Description = s.Description ?? string.Empty, + }).ToList() + } + ]; + } + + internal static async Task NotInVoiceChannel(ApplicationCommandContext context, Func, Task> respondAsync) + { + if (context.Guild is null) + { + var notInGuildMessage = + CreateMessage("This command can only be used in a server."); + await respondAsync(InteractionCallback.Message(notInGuildMessage)); + return true; + } + + if (!context.Guild.VoiceStates.TryGetValue(context.User.Id, out _)) + { + var notInVoiceChannelMessage = + CreateMessage("You must be in a voice channel to use this command."); + await respondAsync(InteractionCallback.Message(notInVoiceChannelMessage)); + return true; + } + + return false; + } + + internal record ComponentModel(string Title, string Url, string? Description = null); + + } \ No newline at end of file diff --git a/src/Infrastructure/Commands/MiscCommands.cs b/src/Infrastructure/Commands/MiscCommands.cs index 7705650..ce736c9 100644 --- a/src/Infrastructure/Commands/MiscCommands.cs +++ b/src/Infrastructure/Commands/MiscCommands.cs @@ -1,35 +1,35 @@ -using Application.Interfaces.Services; -using Application.Services; -using Microsoft.Extensions.DependencyInjection; -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; - -namespace Infrastructure.Commands; - -[SlashCommand("random", "Telling random jokes, quotes, and more")] -public class MiscCommands( - [FromKeyedServices(nameof(JokeService))] - IRandomService jokeService, - [FromKeyedServices(nameof(QuoteService))] - IRandomService quoteService) - : ApplicationCommandModule -{ - [SubSlashCommand("joke", "Tell a random joke")] - public async Task TellJokeAsync() - { - var result = await jokeService.GetAsync(); - - var message = CommandUtils.CreateMessage(result); - message.Tts = true; - await RespondAsync(InteractionCallback.Message(message)); - } - - [SubSlashCommand("quote", "Tell a random quote")] - public async Task TellQuoteAsync() - { - var result = await quoteService.GetAsync(); - var message = CommandUtils.CreateMessage(result); - message.Tts = true; - await RespondAsync(InteractionCallback.Message(message)); - } +using Application.Interfaces.Services; +using Application.Services; +using Microsoft.Extensions.DependencyInjection; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +namespace Infrastructure.Commands; + +[SlashCommand("random", "Telling random jokes, quotes, and more")] +public class MiscCommands( + [FromKeyedServices(nameof(JokeService))] + IRandomService jokeService, + [FromKeyedServices(nameof(QuoteService))] + IRandomService quoteService) + : ApplicationCommandModule +{ + [SubSlashCommand("joke", "Tell a random joke")] + public async Task TellJokeAsync() + { + var result = await jokeService.GetAsync(); + + var message = CommandUtils.CreateMessage(result); + message.Tts = true; + await RespondAsync(InteractionCallback.Message(message)); + } + + [SubSlashCommand("quote", "Tell a random quote")] + public async Task TellQuoteAsync() + { + var result = await quoteService.GetAsync(); + var message = CommandUtils.CreateMessage(result); + message.Tts = true; + await RespondAsync(InteractionCallback.Message(message)); + } } \ No newline at end of file diff --git a/src/Infrastructure/Commands/MusicActionCommands.cs b/src/Infrastructure/Commands/MusicActionCommands.cs index ed02949..3287919 100644 --- a/src/Infrastructure/Commands/MusicActionCommands.cs +++ b/src/Infrastructure/Commands/MusicActionCommands.cs @@ -1,121 +1,121 @@ -using Application.Interfaces.Services; -using Domain.Common; -using Domain.Eventing; -using Domain.Events; -using Infrastructure.Services; -using Microsoft.Extensions.DependencyInjection; -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; -using NetCord.Services.ComponentInteractions; - -namespace Infrastructure.Commands; - -public class MusicActionCommands(IScopeExecutor executor, IGuildMusicService guildMusicService) - : ApplicationCommandModule -{ - [SlashCommand("stop", "Stop playing and clear the queue")] - public async Task Stop() - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - DispatchEvent(new EventType.Stop(Context.Guild!.Id)); - var message = - CommandUtils.CreateMessage("Stopping playback and clearing the queue."); - await RespondAsync(InteractionCallback.Message(message)); - } - - [SlashCommand("skip", "Skip the current track")] - public async Task Skip() - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - DispatchEvent(new EventType.Skip(Context.Guild!.Id)); - var message = CommandUtils.CreateMessage("Skipping the current track."); - await RespondAsync(InteractionCallback.Message(message)); - } - - [SlashCommand("playlist", "Show the current playlist")] - public async Task Playlist() - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - var requests = guildMusicService.GetAllRequests(Context.Guild!.Id); - if (requests.Length == 0) - await RespondAsync(InteractionCallback.Message("No songs in queue.")); - else - { - await executor.ExecuteAsync(async serviceProvider => - { - var youtubeService = serviceProvider.GetRequiredKeyedService(nameof(YoutubeService)); - var songs = requests.Select(async r => - { - var title = r.VideoTitle ?? await youtubeService.GetVideoTitleAsync( - r.VideoUrl ?? (r.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]!, - CancellationToken.None); - return title; - } - ).Take(20).ToList(); - - var titles = await Task.WhenAll(songs); - - var response = "Queues: " + Environment.NewLine + string.Join(Environment.NewLine, - titles.Select((title, index) => - { - var isPlayingNowMsg = index == 0 ? "(Playing now)" : ""; - return $"{index + 1}. {title} {isPlayingNowMsg}"; - })); - await RespondAsync(InteractionCallback.Message(response)); - }); - } - } - - [SlashCommand("rewind", "Rewind the current track")] - public async Task Rewind() - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - InteractionMessageProperties message; - if (guildMusicService.GetNowPlaying(Context.Guild!.Id) is null) - { - message = CommandUtils.CreateMessage("No songs in queue."); - await RespondAsync(InteractionCallback.Message(message)); - return; - } - - guildMusicService.Rewind(Context.Guild.Id); - message = CommandUtils.CreateMessage("Rewinding the current track."); - await RespondAsync(InteractionCallback.Message(message)); - } - - [SlashCommand("statistics", "Show some statistics")] - public async Task Statistics() - { - var statisticWebsiteUrl = new Uri("https://rytho.standleypg.com/"); - var message = - CommandUtils.CreateMessage( - $"You can find the statistics of this bot at: {statisticWebsiteUrl}"); - await RespondAsync(InteractionCallback.Message(message)); - } - - private void DispatchEvent(TEvent @event) where TEvent : IEvent - { - executor.Execute(serviceProvider => - { - var eventDispatcher = serviceProvider.GetRequiredService(); - - eventDispatcher.Dispatch(@event); - }); - } +using Application.Interfaces.Services; +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; +using NetCord.Services.ComponentInteractions; + +namespace Infrastructure.Commands; + +public class MusicActionCommands(IScopeExecutor executor, IGuildMusicService guildMusicService) + : ApplicationCommandModule +{ + [SlashCommand("stop", "Stop playing and clear the queue")] + public async Task Stop() + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + DispatchEvent(new EventType.Stop(Context.Guild!.Id)); + var message = + CommandUtils.CreateMessage("Stopping playback and clearing the queue."); + await RespondAsync(InteractionCallback.Message(message)); + } + + [SlashCommand("skip", "Skip the current track")] + public async Task Skip() + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + DispatchEvent(new EventType.Skip(Context.Guild!.Id)); + var message = CommandUtils.CreateMessage("Skipping the current track."); + await RespondAsync(InteractionCallback.Message(message)); + } + + [SlashCommand("playlist", "Show the current playlist")] + public async Task Playlist() + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + var requests = guildMusicService.GetAllRequests(Context.Guild!.Id); + if (requests.Length == 0) + await RespondAsync(InteractionCallback.Message("No songs in queue.")); + else + { + await executor.ExecuteAsync(async serviceProvider => + { + var youtubeService = serviceProvider.GetRequiredKeyedService(nameof(YoutubeService)); + var songs = requests.Select(async r => + { + var title = r.VideoTitle ?? await youtubeService.GetVideoTitleAsync( + r.VideoUrl ?? (r.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]!, + CancellationToken.None); + return title; + } + ).Take(20).ToList(); + + var titles = await Task.WhenAll(songs); + + var response = "Queues: " + Environment.NewLine + string.Join(Environment.NewLine, + titles.Select((title, index) => + { + var isPlayingNowMsg = index == 0 ? "(Playing now)" : ""; + return $"{index + 1}. {title} {isPlayingNowMsg}"; + })); + await RespondAsync(InteractionCallback.Message(response)); + }); + } + } + + [SlashCommand("rewind", "Rewind the current track")] + public async Task Rewind() + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + InteractionMessageProperties message; + if (guildMusicService.GetNowPlaying(Context.Guild!.Id) is null) + { + message = CommandUtils.CreateMessage("No songs in queue."); + await RespondAsync(InteractionCallback.Message(message)); + return; + } + + guildMusicService.Rewind(Context.Guild.Id); + message = CommandUtils.CreateMessage("Rewinding the current track."); + await RespondAsync(InteractionCallback.Message(message)); + } + + [SlashCommand("statistics", "Show some statistics")] + public async Task Statistics() + { + var statisticWebsiteUrl = new Uri("https://rytho.standleypg.com/"); + var message = + CommandUtils.CreateMessage( + $"You can find the statistics of this bot at: {statisticWebsiteUrl}"); + await RespondAsync(InteractionCallback.Message(message)); + } + + private void DispatchEvent(TEvent @event) where TEvent : IEvent + { + executor.Execute(serviceProvider => + { + var eventDispatcher = serviceProvider.GetRequiredService(); + + eventDispatcher.Dispatch(@event); + }); + } } \ No newline at end of file diff --git a/src/Infrastructure/Commands/MusicPlayCommands.cs b/src/Infrastructure/Commands/MusicPlayCommands.cs index 3c151e9..447aa25 100644 --- a/src/Infrastructure/Commands/MusicPlayCommands.cs +++ b/src/Infrastructure/Commands/MusicPlayCommands.cs @@ -1,99 +1,99 @@ -using Application.Interfaces.Services; -using Domain.Common; -using Microsoft.Extensions.DependencyInjection; -using NetCord.Rest; -using NetCord.Services.ApplicationCommands; -using NetCord.Services.Commands; -using YoutubeExplode; -using YoutubeExplode.Common; - -namespace Infrastructure.Commands; - -[SlashCommand("play", "Play a track from Youtube or a radio station")] -public class PlayCommand(IScopeExecutor executor) : ApplicationCommandModule -{ - [SubSlashCommand("music", "Play a track from Youtube")] - public async Task MusicPlayer([CommandParameter(Remainder = true, Name = "song title")] string command) - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - await executor.ExecuteAsync(async serviceProvider => - { - // Blacklist enforcement happens at selection time (NetCordInteraction.Play), - // where the actual video URL is known. - var youtubeClient = serviceProvider.GetRequiredService(); - var message = CommandUtils.CreateMessage("Select a track to play:"); - - var source = - await youtubeClient.Search.GetVideosAsync(command) - .CollectAsync(5); - - message.Components = - CommandUtils.CreateComponent(source.Select(s => - new CommandUtils.ComponentModel(s.Title, s.Url, s.Author.ChannelTitle))); - - await RespondAsync(InteractionCallback.Message(message)); - }); - } - - [SubSlashCommand("radio", "Play a radio station")] - public async Task RadioPlayer() - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - await executor.ExecuteAsync(async serviceProvider => - { - var radioSourceService = serviceProvider.GetRequiredService(); - var message = - CommandUtils.CreateMessage("Select a radio station to play:"); - - var radiosSourceList = (await radioSourceService.GetAllRadioSourcesAsync(CancellationToken.None)).Where(rs => rs.IsActive); - message.Components = - CommandUtils.CreateComponent(radiosSourceList.Select(rs => - new CommandUtils.ComponentModel(rs.Name, rs.Id.ToString()))); - - await RespondAsync(InteractionCallback.Message(message)); - }); - } - - [SubSlashCommand("playlist", "Play a track from a Youtube playlist URL")] - public async Task PlayFromPlaylist([CommandParameter(Remainder = true, Name = "playlist url")] string playlistUrl) - { - if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) - { - return; - } - - await executor.ExecuteAsync(async serviceProvider => - { - var youtubeClient = serviceProvider.GetRequiredService(); - var message = CommandUtils.CreateMessage("Select a track to play:"); - - try - { - var playlist = await youtubeClient.Playlists.GetAsync(playlistUrl); - - message.Components = - CommandUtils.CreateComponent(new List - { - new(playlist.Title, playlist.Id, "Playlist") - }, Constants.CustomIds.PlayListPlay); - - await RespondAsync(InteractionCallback.Message(message)); - } - catch (Exception) - { - var errorMessage = - CommandUtils.CreateMessage( - "Failed to retrieve the playlist. Please ensure the URL is correct."); - await RespondAsync(InteractionCallback.Message(errorMessage)); - } - }); - } +using Application.Interfaces.Services; +using Domain.Common; +using Microsoft.Extensions.DependencyInjection; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; +using NetCord.Services.Commands; +using YoutubeExplode; +using YoutubeExplode.Common; + +namespace Infrastructure.Commands; + +[SlashCommand("play", "Play a track from Youtube or a radio station")] +public class PlayCommand(IScopeExecutor executor) : ApplicationCommandModule +{ + [SubSlashCommand("music", "Play a track from Youtube")] + public async Task MusicPlayer([CommandParameter(Remainder = true, Name = "song title")] string command) + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + await executor.ExecuteAsync(async serviceProvider => + { + // Blacklist enforcement happens at selection time (NetCordInteraction.Play), + // where the actual video URL is known. + var youtubeClient = serviceProvider.GetRequiredService(); + var message = CommandUtils.CreateMessage("Select a track to play:"); + + var source = + await youtubeClient.Search.GetVideosAsync(command) + .CollectAsync(5); + + message.Components = + CommandUtils.CreateComponent(source.Select(s => + new CommandUtils.ComponentModel(s.Title, s.Url, s.Author.ChannelTitle))); + + await RespondAsync(InteractionCallback.Message(message)); + }); + } + + [SubSlashCommand("radio", "Play a radio station")] + public async Task RadioPlayer() + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + await executor.ExecuteAsync(async serviceProvider => + { + var radioSourceService = serviceProvider.GetRequiredService(); + var message = + CommandUtils.CreateMessage("Select a radio station to play:"); + + var radiosSourceList = (await radioSourceService.GetAllRadioSourcesAsync(CancellationToken.None)).Where(rs => rs.IsActive); + message.Components = + CommandUtils.CreateComponent(radiosSourceList.Select(rs => + new CommandUtils.ComponentModel(rs.Name, rs.Id.ToString()))); + + await RespondAsync(InteractionCallback.Message(message)); + }); + } + + [SubSlashCommand("playlist", "Play a track from a Youtube playlist URL")] + public async Task PlayFromPlaylist([CommandParameter(Remainder = true, Name = "playlist url")] string playlistUrl) + { + if (await CommandUtils.NotInVoiceChannel(Context, (message) => RespondAsync(message))) + { + return; + } + + await executor.ExecuteAsync(async serviceProvider => + { + var youtubeClient = serviceProvider.GetRequiredService(); + var message = CommandUtils.CreateMessage("Select a track to play:"); + + try + { + var playlist = await youtubeClient.Playlists.GetAsync(playlistUrl); + + message.Components = + CommandUtils.CreateComponent(new List + { + new(playlist.Title, playlist.Id, "Playlist") + }, Constants.CustomIds.PlayListPlay); + + await RespondAsync(InteractionCallback.Message(message)); + } + catch (Exception) + { + var errorMessage = + CommandUtils.CreateMessage( + "Failed to retrieve the playlist. Please ensure the URL is correct."); + await RespondAsync(InteractionCallback.Message(errorMessage)); + } + }); + } } \ No newline at end of file diff --git a/src/Infrastructure/CompiledModels/DiscordBotContextAssemblyAttributes.cs b/src/Infrastructure/CompiledModels/DiscordBotContextAssemblyAttributes.cs index 332544a..03ac901 100644 --- a/src/Infrastructure/CompiledModels/DiscordBotContextAssemblyAttributes.cs +++ b/src/Infrastructure/CompiledModels/DiscordBotContextAssemblyAttributes.cs @@ -1,9 +1,9 @@ -// -using Infrastructure.CompiledModels; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore.Infrastructure; - -#pragma warning disable 219, 612, 618 -#nullable disable - -[assembly: DbContextModel(typeof(DiscordBotContext), typeof(DiscordBotContextModel))] +// +using Infrastructure.CompiledModels; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#pragma warning disable 219, 612, 618 +#nullable disable + +[assembly: DbContextModel(typeof(DiscordBotContext), typeof(DiscordBotContextModel))] diff --git a/src/Infrastructure/CompiledModels/DiscordBotContextModel.cs b/src/Infrastructure/CompiledModels/DiscordBotContextModel.cs index 22c0e0b..8b6df97 100644 --- a/src/Infrastructure/CompiledModels/DiscordBotContextModel.cs +++ b/src/Infrastructure/CompiledModels/DiscordBotContextModel.cs @@ -1,48 +1,48 @@ -// -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - [DbContext(typeof(DiscordBotContext))] - public partial class DiscordBotContextModel : RuntimeModel - { - private static readonly bool _useOldBehavior31751 = - System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751; - - static DiscordBotContextModel() - { - var model = new DiscordBotContextModel(); - - if (_useOldBehavior31751) - { - model.Initialize(); - } - else - { - var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024); - thread.Start(); - thread.Join(); - - void RunInitialization() - { - model.Initialize(); - } - } - - model.Customize(); - _instance = (DiscordBotContextModel)model.FinalizeModel(); - } - - private static DiscordBotContextModel _instance; - public static IModel Instance => _instance; - - partial void Initialize(); - - partial void Customize(); - } -} +// +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + [DbContext(typeof(DiscordBotContext))] + public partial class DiscordBotContextModel : RuntimeModel + { + private static readonly bool _useOldBehavior31751 = + System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751; + + static DiscordBotContextModel() + { + var model = new DiscordBotContextModel(); + + if (_useOldBehavior31751) + { + model.Initialize(); + } + else + { + var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024); + thread.Start(); + thread.Join(); + + void RunInitialization() + { + model.Initialize(); + } + } + + model.Customize(); + _instance = (DiscordBotContextModel)model.FinalizeModel(); + } + + private static DiscordBotContextModel _instance; + public static IModel Instance => _instance; + + partial void Initialize(); + + partial void Customize(); + } +} diff --git a/src/Infrastructure/CompiledModels/DiscordBotContextModelBuilder.cs b/src/Infrastructure/CompiledModels/DiscordBotContextModelBuilder.cs index 1cd0f5c..0b19135 100644 --- a/src/Infrastructure/CompiledModels/DiscordBotContextModelBuilder.cs +++ b/src/Infrastructure/CompiledModels/DiscordBotContextModelBuilder.cs @@ -1,39 +1,39 @@ -// -using System; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - public partial class DiscordBotContextModel - { - private DiscordBotContextModel() - : base(skipDetectChanges: false, modelId: new Guid("77a85d27-fb4f-48e5-a120-f0aad4b87f25"), entityTypeCount: 4) - { - } - - partial void Initialize() - { - var playHistory = PlayHistoryEntityType.Create(this); - var radioSource = RadioSourceEntityType.Create(this); - var song = SongEntityType.Create(this); - var user = UserEntityType.Create(this); - - PlayHistoryEntityType.CreateForeignKey1(playHistory, song); - PlayHistoryEntityType.CreateForeignKey2(playHistory, user); - - PlayHistoryEntityType.CreateAnnotations(playHistory); - RadioSourceEntityType.CreateAnnotations(radioSource); - SongEntityType.CreateAnnotations(song); - UserEntityType.CreateAnnotations(user); - - AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - AddAnnotation("ProductVersion", "10.0.3"); - AddAnnotation("Relational:MaxIdentifierLength", 63); - } - } -} +// +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + public partial class DiscordBotContextModel + { + private DiscordBotContextModel() + : base(skipDetectChanges: false, modelId: new Guid("77a85d27-fb4f-48e5-a120-f0aad4b87f25"), entityTypeCount: 4) + { + } + + partial void Initialize() + { + var playHistory = PlayHistoryEntityType.Create(this); + var radioSource = RadioSourceEntityType.Create(this); + var song = SongEntityType.Create(this); + var user = UserEntityType.Create(this); + + PlayHistoryEntityType.CreateForeignKey1(playHistory, song); + PlayHistoryEntityType.CreateForeignKey2(playHistory, user); + + PlayHistoryEntityType.CreateAnnotations(playHistory); + RadioSourceEntityType.CreateAnnotations(radioSource); + SongEntityType.CreateAnnotations(song); + UserEntityType.CreateAnnotations(user); + + AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + AddAnnotation("ProductVersion", "10.0.3"); + AddAnnotation("Relational:MaxIdentifierLength", 63); + } + } +} diff --git a/src/Infrastructure/CompiledModels/PlayHistoryEntityType.cs b/src/Infrastructure/CompiledModels/PlayHistoryEntityType.cs index 7c108a5..b3e4f2e 100644 --- a/src/Infrastructure/CompiledModels/PlayHistoryEntityType.cs +++ b/src/Infrastructure/CompiledModels/PlayHistoryEntityType.cs @@ -1,167 +1,167 @@ -// -using System; -using System.Collections.Generic; -using System.Reflection; -using Domain.Common; -using Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - [EntityFrameworkInternal] - public partial class PlayHistoryEntityType - { - public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) - { - var runtimeEntityType = model.AddEntityType( - "Domain.Entities.PlayHistory", - typeof(PlayHistory), - baseEntityType, - propertyCount: 7, - navigationCount: 2, - foreignKeyCount: 2, - unnamedIndexCount: 2, - keyCount: 1); - - var id = runtimeEntityType.AddProperty( - "Id", - typeof(Guid), - propertyInfo: typeof(PlayHistory).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - valueGenerated: ValueGenerated.OnAdd, - afterSaveBehavior: PropertySaveBehavior.Throw, - sentinel: new Guid("00000000-0000-0000-0000-000000000000")); - id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var createdAt = runtimeEntityType.AddProperty( - "CreatedAt", - typeof(DateTimeOffset), - propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var playedAt = runtimeEntityType.AddProperty( - "PlayedAt", - typeof(DateTimeOffset), - propertyInfo: typeof(PlayHistory).GetProperty("PlayedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - playedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var songId = runtimeEntityType.AddProperty( - "SongId", - typeof(Guid), - propertyInfo: typeof(PlayHistory).GetProperty("SongId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new Guid("00000000-0000-0000-0000-000000000000")); - songId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var totalPlays = runtimeEntityType.AddProperty( - "TotalPlays", - typeof(int), - propertyInfo: typeof(PlayHistory).GetProperty("TotalPlays", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: 0); - totalPlays.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var updatedAt = runtimeEntityType.AddProperty( - "UpdatedAt", - typeof(DateTimeOffset?), - propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); - updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var userId = runtimeEntityType.AddProperty( - "UserId", - typeof(ulong), - propertyInfo: typeof(PlayHistory).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - userId.SetSentinelFromProviderValue(0m); - userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var key = runtimeEntityType.AddKey( - new[] { id }); - runtimeEntityType.SetPrimaryKey(key); - - var index = runtimeEntityType.AddIndex( - new[] { songId }); - - var index0 = runtimeEntityType.AddIndex( - new[] { userId }); - - return runtimeEntityType; - } - - public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) - { - var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("SongId") }, - principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id") }), - principalEntityType, - deleteBehavior: DeleteBehavior.Cascade, - required: true); - - var song = declaringEntityType.AddNavigation("Song", - runtimeForeignKey, - onDependent: true, - typeof(Song), - propertyInfo: typeof(PlayHistory).GetProperty("Song", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - - var playHistories = principalEntityType.AddNavigation("PlayHistories", - runtimeForeignKey, - onDependent: false, - typeof(ICollection), - propertyInfo: typeof(Song).GetProperty("PlayHistories", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - - return runtimeForeignKey; - } - - public static RuntimeForeignKey CreateForeignKey2(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) - { - var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("UserId") }, - principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id") }), - principalEntityType, - deleteBehavior: DeleteBehavior.Cascade, - required: true); - - var user = declaringEntityType.AddNavigation("User", - runtimeForeignKey, - onDependent: true, - typeof(User), - propertyInfo: typeof(PlayHistory).GetProperty("User", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - - var playHistories = principalEntityType.AddNavigation("PlayHistories", - runtimeForeignKey, - onDependent: false, - typeof(ICollection), - propertyInfo: typeof(User).GetProperty("PlayHistories", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(User).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - - return runtimeForeignKey; - } - - public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) - { - runtimeEntityType.AddAnnotation("Relational:FunctionName", null); - runtimeEntityType.AddAnnotation("Relational:Schema", null); - runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); - runtimeEntityType.AddAnnotation("Relational:TableName", "PlayHistory"); - runtimeEntityType.AddAnnotation("Relational:ViewName", null); - runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); - - Customize(runtimeEntityType); - } - - static partial void Customize(RuntimeEntityType runtimeEntityType); - } -} +// +using System; +using System.Collections.Generic; +using System.Reflection; +using Domain.Common; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + [EntityFrameworkInternal] + public partial class PlayHistoryEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Domain.Entities.PlayHistory", + typeof(PlayHistory), + baseEntityType, + propertyCount: 7, + navigationCount: 2, + foreignKeyCount: 2, + unnamedIndexCount: 2, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(PlayHistory).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var createdAt = runtimeEntityType.AddProperty( + "CreatedAt", + typeof(DateTimeOffset), + propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var playedAt = runtimeEntityType.AddProperty( + "PlayedAt", + typeof(DateTimeOffset), + propertyInfo: typeof(PlayHistory).GetProperty("PlayedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + playedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var songId = runtimeEntityType.AddProperty( + "SongId", + typeof(Guid), + propertyInfo: typeof(PlayHistory).GetProperty("SongId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + songId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var totalPlays = runtimeEntityType.AddProperty( + "TotalPlays", + typeof(int), + propertyInfo: typeof(PlayHistory).GetProperty("TotalPlays", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: 0); + totalPlays.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var updatedAt = runtimeEntityType.AddProperty( + "UpdatedAt", + typeof(DateTimeOffset?), + propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var userId = runtimeEntityType.AddProperty( + "UserId", + typeof(ulong), + propertyInfo: typeof(PlayHistory).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + userId.SetSentinelFromProviderValue(0m); + userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { songId }); + + var index0 = runtimeEntityType.AddIndex( + new[] { userId }); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("SongId") }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id") }), + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + var song = declaringEntityType.AddNavigation("Song", + runtimeForeignKey, + onDependent: true, + typeof(Song), + propertyInfo: typeof(PlayHistory).GetProperty("Song", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var playHistories = principalEntityType.AddNavigation("PlayHistories", + runtimeForeignKey, + onDependent: false, + typeof(ICollection), + propertyInfo: typeof(Song).GetProperty("PlayHistories", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + return runtimeForeignKey; + } + + public static RuntimeForeignKey CreateForeignKey2(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("UserId") }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id") }), + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + var user = declaringEntityType.AddNavigation("User", + runtimeForeignKey, + onDependent: true, + typeof(User), + propertyInfo: typeof(PlayHistory).GetProperty("User", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(PlayHistory).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + var playHistories = principalEntityType.AddNavigation("PlayHistories", + runtimeForeignKey, + onDependent: false, + typeof(ICollection), + propertyInfo: typeof(User).GetProperty("PlayHistories", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(User).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "PlayHistory"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Infrastructure/CompiledModels/RadioSourceEntityType.cs b/src/Infrastructure/CompiledModels/RadioSourceEntityType.cs index a6cbbb2..1a7b8e3 100644 --- a/src/Infrastructure/CompiledModels/RadioSourceEntityType.cs +++ b/src/Infrastructure/CompiledModels/RadioSourceEntityType.cs @@ -1,96 +1,96 @@ -// -using System; -using System.Reflection; -using Domain.Common; -using Domain.Entities; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - [EntityFrameworkInternal] - public partial class RadioSourceEntityType - { - public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) - { - var runtimeEntityType = model.AddEntityType( - "Domain.Entities.RadioSource", - typeof(RadioSource), - baseEntityType, - propertyCount: 6, - keyCount: 1); - - var id = runtimeEntityType.AddProperty( - "Id", - typeof(Guid), - propertyInfo: typeof(RadioSource).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - valueGenerated: ValueGenerated.OnAdd, - afterSaveBehavior: PropertySaveBehavior.Throw, - sentinel: new Guid("00000000-0000-0000-0000-000000000000")); - id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var createdAt = runtimeEntityType.AddProperty( - "CreatedAt", - typeof(DateTimeOffset), - propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var isActive = runtimeEntityType.AddProperty( - "IsActive", - typeof(bool), - propertyInfo: typeof(RadioSource).GetProperty("IsActive", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: false); - isActive.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var name = runtimeEntityType.AddProperty( - "Name", - typeof(string), - propertyInfo: typeof(RadioSource).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var sourceUrl = runtimeEntityType.AddProperty( - "SourceUrl", - typeof(string), - propertyInfo: typeof(RadioSource).GetProperty("SourceUrl", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - sourceUrl.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var updatedAt = runtimeEntityType.AddProperty( - "UpdatedAt", - typeof(DateTimeOffset?), - propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); - updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var key = runtimeEntityType.AddKey( - new[] { id }); - runtimeEntityType.SetPrimaryKey(key); - - return runtimeEntityType; - } - - public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) - { - runtimeEntityType.AddAnnotation("Relational:FunctionName", null); - runtimeEntityType.AddAnnotation("Relational:Schema", null); - runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); - runtimeEntityType.AddAnnotation("Relational:TableName", "RadioSources"); - runtimeEntityType.AddAnnotation("Relational:ViewName", null); - runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); - - Customize(runtimeEntityType); - } - - static partial void Customize(RuntimeEntityType runtimeEntityType); - } -} +// +using System; +using System.Reflection; +using Domain.Common; +using Domain.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + [EntityFrameworkInternal] + public partial class RadioSourceEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Domain.Entities.RadioSource", + typeof(RadioSource), + baseEntityType, + propertyCount: 6, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(RadioSource).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var createdAt = runtimeEntityType.AddProperty( + "CreatedAt", + typeof(DateTimeOffset), + propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var isActive = runtimeEntityType.AddProperty( + "IsActive", + typeof(bool), + propertyInfo: typeof(RadioSource).GetProperty("IsActive", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: false); + isActive.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var name = runtimeEntityType.AddProperty( + "Name", + typeof(string), + propertyInfo: typeof(RadioSource).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var sourceUrl = runtimeEntityType.AddProperty( + "SourceUrl", + typeof(string), + propertyInfo: typeof(RadioSource).GetProperty("SourceUrl", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(RadioSource).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + sourceUrl.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var updatedAt = runtimeEntityType.AddProperty( + "UpdatedAt", + typeof(DateTimeOffset?), + propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "RadioSources"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Infrastructure/CompiledModels/SongEntityType.cs b/src/Infrastructure/CompiledModels/SongEntityType.cs index ddacf3a..b60f3ce 100644 --- a/src/Infrastructure/CompiledModels/SongEntityType.cs +++ b/src/Infrastructure/CompiledModels/SongEntityType.cs @@ -1,102 +1,102 @@ -// -using System; -using System.Reflection; -using Domain.Common; -using Domain.Entities; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - [EntityFrameworkInternal] - public partial class SongEntityType - { - public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) - { - var runtimeEntityType = model.AddEntityType( - "Domain.Entities.Song", - typeof(Song), - baseEntityType, - propertyCount: 6, - navigationCount: 1, - unnamedIndexCount: 1, - keyCount: 1); - - var id = runtimeEntityType.AddProperty( - "Id", - typeof(Guid), - propertyInfo: typeof(Song).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - valueGenerated: ValueGenerated.OnAdd, - afterSaveBehavior: PropertySaveBehavior.Throw, - sentinel: new Guid("00000000-0000-0000-0000-000000000000")); - id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var createdAt = runtimeEntityType.AddProperty( - "CreatedAt", - typeof(DateTimeOffset), - propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var isBlacklisted = runtimeEntityType.AddProperty( - "IsBlacklisted", - typeof(bool), - propertyInfo: typeof(Song).GetProperty("IsBlacklisted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: false); - isBlacklisted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var sourceUrl = runtimeEntityType.AddProperty( - "SourceUrl", - typeof(string), - propertyInfo: typeof(Song).GetProperty("SourceUrl", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - sourceUrl.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var title = runtimeEntityType.AddProperty( - "Title", - typeof(string), - propertyInfo: typeof(Song).GetProperty("Title", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Song).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - title.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var updatedAt = runtimeEntityType.AddProperty( - "UpdatedAt", - typeof(DateTimeOffset?), - propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); - updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var key = runtimeEntityType.AddKey( - new[] { id }); - runtimeEntityType.SetPrimaryKey(key); - - var index = runtimeEntityType.AddIndex( - new[] { sourceUrl }, - unique: true); - - return runtimeEntityType; - } - - public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) - { - runtimeEntityType.AddAnnotation("Relational:FunctionName", null); - runtimeEntityType.AddAnnotation("Relational:Schema", null); - runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); - runtimeEntityType.AddAnnotation("Relational:TableName", "Songs"); - runtimeEntityType.AddAnnotation("Relational:ViewName", null); - runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); - - Customize(runtimeEntityType); - } - - static partial void Customize(RuntimeEntityType runtimeEntityType); - } -} +// <auto-generated /> +using System; +using System.Reflection; +using Domain.Common; +using Domain.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + [EntityFrameworkInternal] + public partial class SongEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Domain.Entities.Song", + typeof(Song), + baseEntityType, + propertyCount: 6, + navigationCount: 1, + unnamedIndexCount: 1, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(Song).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Song).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var createdAt = runtimeEntityType.AddProperty( + "CreatedAt", + typeof(DateTimeOffset), + propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var isBlacklisted = runtimeEntityType.AddProperty( + "IsBlacklisted", + typeof(bool), + propertyInfo: typeof(Song).GetProperty("IsBlacklisted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Song).GetField("<IsBlacklisted>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: false); + isBlacklisted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var sourceUrl = runtimeEntityType.AddProperty( + "SourceUrl", + typeof(string), + propertyInfo: typeof(Song).GetProperty("SourceUrl", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Song).GetField("<SourceUrl>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + sourceUrl.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var title = runtimeEntityType.AddProperty( + "Title", + typeof(string), + propertyInfo: typeof(Song).GetProperty("Title", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Song).GetField("<Title>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + title.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var updatedAt = runtimeEntityType.AddProperty( + "UpdatedAt", + typeof(DateTimeOffset?), + propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { sourceUrl }, + unique: true); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "Songs"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Infrastructure/CompiledModels/UserEntityType.cs b/src/Infrastructure/CompiledModels/UserEntityType.cs index 6918477..4d4bdb7 100644 --- a/src/Infrastructure/CompiledModels/UserEntityType.cs +++ b/src/Infrastructure/CompiledModels/UserEntityType.cs @@ -1,103 +1,103 @@ -// <auto-generated /> -using System; -using System.Reflection; -using Domain.Common; -using Domain.Entities; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#pragma warning disable 219, 612, 618 -#nullable disable - -namespace Infrastructure.CompiledModels -{ - [EntityFrameworkInternal] - public partial class UserEntityType - { - public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) - { - var runtimeEntityType = model.AddEntityType( - "Domain.Entities.User", - typeof(User), - baseEntityType, - propertyCount: 6, - navigationCount: 1, - unnamedIndexCount: 1, - keyCount: 1); - - var id = runtimeEntityType.AddProperty( - "Id", - typeof(ulong), - propertyInfo: typeof(User).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(User).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - valueGenerated: ValueGenerated.OnAdd, - afterSaveBehavior: PropertySaveBehavior.Throw); - id.SetSentinelFromProviderValue(0m); - id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var createdAt = runtimeEntityType.AddProperty( - "CreatedAt", - typeof(DateTimeOffset), - propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var displayName = runtimeEntityType.AddProperty( - "DisplayName", - typeof(string), - propertyInfo: typeof(User).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(User).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); - displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var totalSongsPlayed = runtimeEntityType.AddProperty( - "TotalSongsPlayed", - typeof(int), - propertyInfo: typeof(User).GetProperty("TotalSongsPlayed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(User).GetField("<TotalSongsPlayed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - sentinel: 0); - totalSongsPlayed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var updatedAt = runtimeEntityType.AddProperty( - "UpdatedAt", - typeof(DateTimeOffset?), - propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(EntityBase).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), - nullable: true); - updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var username = runtimeEntityType.AddProperty( - "Username", - typeof(string), - propertyInfo: typeof(User).GetProperty("Username", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(User).GetField("<Username>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - username.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); - - var key = runtimeEntityType.AddKey( - new[] { id }); - runtimeEntityType.SetPrimaryKey(key); - - var index = runtimeEntityType.AddIndex( - new[] { username }, - unique: true); - - return runtimeEntityType; - } - - public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) - { - runtimeEntityType.AddAnnotation("Relational:FunctionName", null); - runtimeEntityType.AddAnnotation("Relational:Schema", null); - runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); - runtimeEntityType.AddAnnotation("Relational:TableName", "Users"); - runtimeEntityType.AddAnnotation("Relational:ViewName", null); - runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); - - Customize(runtimeEntityType); - } - - static partial void Customize(RuntimeEntityType runtimeEntityType); - } -} +// <auto-generated /> +using System; +using System.Reflection; +using Domain.Common; +using Domain.Entities; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace Infrastructure.CompiledModels +{ + [EntityFrameworkInternal] + public partial class UserEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "Domain.Entities.User", + typeof(User), + baseEntityType, + propertyCount: 6, + navigationCount: 1, + unnamedIndexCount: 1, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(ulong), + propertyInfo: typeof(User).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(User).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw); + id.SetSentinelFromProviderValue(0m); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var createdAt = runtimeEntityType.AddProperty( + "CreatedAt", + typeof(DateTimeOffset), + propertyInfo: typeof(EntityBase).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var displayName = runtimeEntityType.AddProperty( + "DisplayName", + typeof(string), + propertyInfo: typeof(User).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(User).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var totalSongsPlayed = runtimeEntityType.AddProperty( + "TotalSongsPlayed", + typeof(int), + propertyInfo: typeof(User).GetProperty("TotalSongsPlayed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(User).GetField("<TotalSongsPlayed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: 0); + totalSongsPlayed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var updatedAt = runtimeEntityType.AddProperty( + "UpdatedAt", + typeof(DateTimeOffset?), + propertyInfo: typeof(EntityBase).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(EntityBase).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var username = runtimeEntityType.AddProperty( + "Username", + typeof(string), + propertyInfo: typeof(User).GetProperty("Username", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(User).GetField("<Username>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + username.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { username }, + unique: true); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", null); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "Users"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Infrastructure/Data/DiscordBotContext.cs b/src/Infrastructure/Data/DiscordBotContext.cs index 50cfe81..a45768b 100644 --- a/src/Infrastructure/Data/DiscordBotContext.cs +++ b/src/Infrastructure/Data/DiscordBotContext.cs @@ -1,43 +1,43 @@ -using Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Infrastructure.CompiledModels; - -namespace Infrastructure.Data; - -public class DiscordBotContext(DbContextOptions<DiscordBotContext> options) : DbContext(options) -{ - public DbSet<User> Users { get; set; } - public DbSet<Song> Songs { get; set; } - public DbSet<PlayHistory> PlayHistory { get; set; } - public DbSet<RadioSource> RadioSources { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseModel(DiscordBotContextModel.Instance); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity<User>(entity => - { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Username).IsUnique(); - }); - - modelBuilder.Entity<Song>(entity => - { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.SourceUrl).IsUnique(); - }); - - modelBuilder.Entity<PlayHistory>(entity => - { - entity.HasKey(e => e.Id); - }); - - modelBuilder.Entity<RadioSource>(entity => - { - entity.HasKey(e => e.Id); - }); - } +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Infrastructure.CompiledModels; + +namespace Infrastructure.Data; + +public class DiscordBotContext(DbContextOptions<DiscordBotContext> options) : DbContext(options) +{ + public DbSet<User> Users { get; set; } + public DbSet<Song> Songs { get; set; } + public DbSet<PlayHistory> PlayHistory { get; set; } + public DbSet<RadioSource> RadioSources { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseModel(DiscordBotContextModel.Instance); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<User>(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Username).IsUnique(); + }); + + modelBuilder.Entity<Song>(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.SourceUrl).IsUnique(); + }); + + modelBuilder.Entity<PlayHistory>(entity => + { + entity.HasKey(e => e.Id); + }); + + modelBuilder.Entity<RadioSource>(entity => + { + entity.HasKey(e => e.Id); + }); + } } \ No newline at end of file diff --git a/src/Infrastructure/Data/Migrations/20250615114739_Initial.Designer.cs b/src/Infrastructure/Data/Migrations/20250615114739_Initial.Designer.cs index 78bd440..8992488 100644 --- a/src/Infrastructure/Data/Migrations/20250615114739_Initial.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250615114739_Initial.Designer.cs @@ -1,163 +1,163 @@ -// <auto-generated /> -using System; -using Data; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250615114739_Initial")] - partial class Initial - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<DateTime>("PlayedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.PlayHistory", b => - { - b.HasOne("Domain.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Data; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250615114739_Initial")] + partial class Initial + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<DateTime>("PlayedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.PlayHistory", b => + { + b.HasOne("Domain.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250615114739_Initial.cs b/src/Infrastructure/Data/Migrations/20250615114739_Initial.cs index 2f02161..57af84b 100644 --- a/src/Infrastructure/Data/Migrations/20250615114739_Initial.cs +++ b/src/Infrastructure/Data/Migrations/20250615114739_Initial.cs @@ -1,109 +1,109 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.Migrations -{ - /// <inheritdoc /> - public partial class Initial : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Songs", - columns: table => new - { - Id = table.Column<Guid>(type: "uuid", nullable: false), - SourceUrl = table.Column<string>(type: "text", nullable: false), - Title = table.Column<string>(type: "text", nullable: false), - CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") - }, - constraints: table => - { - table.PrimaryKey("PK_Songs", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column<decimal>(type: "numeric(20,0)", nullable: false), - Username = table.Column<string>(type: "text", nullable: false), - DisplayName = table.Column<string>(type: "text", nullable: false), - TotalSongsPlayed = table.Column<int>(type: "integer", nullable: false), - CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "PlayHistory", - columns: table => new - { - Id = table.Column<Guid>(type: "uuid", nullable: false), - PlayedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - UserId = table.Column<decimal>(type: "numeric(20,0)", nullable: false), - SongId = table.Column<Guid>(type: "uuid", nullable: false), - CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") - }, - constraints: table => - { - table.PrimaryKey("PK_PlayHistory", x => x.Id); - table.ForeignKey( - name: "FK_PlayHistory_Songs_SongId", - column: x => x.SongId, - principalTable: "Songs", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_PlayHistory_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_PlayHistory_SongId", - table: "PlayHistory", - column: "SongId"); - - migrationBuilder.CreateIndex( - name: "IX_PlayHistory_UserId", - table: "PlayHistory", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Songs_SourceUrl", - table: "Songs", - column: "SourceUrl", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_Username", - table: "Users", - column: "Username", - unique: true); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "PlayHistory"); - - migrationBuilder.DropTable( - name: "Songs"); - - migrationBuilder.DropTable( - name: "Users"); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// <inheritdoc /> + public partial class Initial : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Songs", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + SourceUrl = table.Column<string>(type: "text", nullable: false), + Title = table.Column<string>(type: "text", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Songs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column<decimal>(type: "numeric(20,0)", nullable: false), + Username = table.Column<string>(type: "text", nullable: false), + DisplayName = table.Column<string>(type: "text", nullable: false), + TotalSongsPlayed = table.Column<int>(type: "integer", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayHistory", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + PlayedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UserId = table.Column<decimal>(type: "numeric(20,0)", nullable: false), + SongId = table.Column<Guid>(type: "uuid", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_PlayHistory", x => x.Id); + table.ForeignKey( + name: "FK_PlayHistory_Songs_SongId", + column: x => x.SongId, + principalTable: "Songs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayHistory_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayHistory_SongId", + table: "PlayHistory", + column: "SongId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayHistory_UserId", + table: "PlayHistory", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Songs_SourceUrl", + table: "Songs", + column: "SourceUrl", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayHistory"); + + migrationBuilder.DropTable( + name: "Songs"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.Designer.cs b/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.Designer.cs index 64effca..7b53f9b 100644 --- a/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.Designer.cs @@ -1,166 +1,166 @@ -// <auto-generated /> -using System; -using Data; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250617022313_Added Blacklisted song")] - partial class AddedBlacklistedsong - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<DateTime>("PlayedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTime>("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTime>("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.PlayHistory", b => - { - b.HasOne("Domain.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Data; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250617022313_Added Blacklisted song")] + partial class AddedBlacklistedsong + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<DateTime>("PlayedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.PlayHistory", b => + { + b.HasOne("Domain.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.cs b/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.cs index 673c5fb..28c0793 100644 --- a/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.cs +++ b/src/Infrastructure/Data/Migrations/20250617022313_Added Blacklisted song.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Data.Migrations -{ - /// <inheritdoc /> - public partial class AddedBlacklistedsong : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn<bool>( - name: "IsBlacklisted", - table: "Songs", - type: "boolean", - nullable: false, - defaultValue: false); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsBlacklisted", - table: "Songs"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// <inheritdoc /> + public partial class AddedBlacklistedsong : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<bool>( + name: "IsBlacklisted", + table: "Songs", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBlacklisted", + table: "Songs"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.Designer.cs b/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.Designer.cs index f105b75..74f5427 100644 --- a/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.Designer.cs @@ -1,151 +1,151 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250620021205_Domain_Improvement")] - partial class Domain_Improvement - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250620021205_Domain_Improvement")] + partial class Domain_Improvement + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.cs b/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.cs index f51a197..9c8b8c0 100644 --- a/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.cs +++ b/src/Infrastructure/Data/Migrations/20250620021205_Domain_Improvement.cs @@ -1,145 +1,145 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class Domain_Improvement : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "CreatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "CreatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "PlayedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "CreatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - oldClrType: typeof(DateTime), - oldType: "timestamp with time zone", - oldDefaultValueSql: "CURRENT_TIMESTAMP"); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<DateTime>( - name: "UpdatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "CreatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "UpdatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "CreatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "UpdatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "PlayedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTime>( - name: "CreatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "CURRENT_TIMESTAMP", - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class Domain_Improvement : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "CreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "CreatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "PlayedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "CreatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTime>( + name: "UpdatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "CreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "UpdatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "CreatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "UpdatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "PlayedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTime>( + name: "CreatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.Designer.cs b/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.Designer.cs index 0a80115..a6af5bf 100644 --- a/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.Designer.cs @@ -1,154 +1,154 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250622074755_Db_Improvement")] - partial class Db_Improvement - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250622074755_Db_Improvement")] + partial class Db_Improvement + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.cs b/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.cs index a78f106..d26bb57 100644 --- a/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.cs +++ b/src/Infrastructure/Data/Migrations/20250622074755_Db_Improvement.cs @@ -1,64 +1,64 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class Db_Improvement : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn<int>( - name: "TotalPlays", - table: "PlayHistory", - type: "integer", - nullable: false, - defaultValue: 0); - - migrationBuilder.Sql(@" - CREATE TEMP TABLE tmp_playhistory_aggregated AS - SELECT - ( - SELECT ph2.""Id"" - FROM ""PlayHistory"" ph2 - WHERE ph2.""UserId"" = ph.""UserId"" - AND ph2.""SongId"" = ph.""SongId"" - ORDER BY ph2.""PlayedAt"" - LIMIT 1 - ) AS ""Id"", - ph.""UserId"", - ph.""SongId"", - MIN(ph.""PlayedAt"") AS ""PlayedAt"", - COUNT(*) AS ""TotalPlays"", - MIN(ph.""CreatedAt"") AS ""CreatedAt"", - MAX(ph.""UpdatedAt"") AS ""UpdatedAt"" - FROM ""PlayHistory"" ph - GROUP BY ph.""UserId"", ph.""SongId""; - - DELETE FROM ""PlayHistory"" - WHERE (""UserId"", ""SongId"") IN ( - SELECT ""UserId"", ""SongId"" FROM tmp_playhistory_aggregated - ); - - INSERT INTO ""PlayHistory"" ( - ""Id"", ""UserId"", ""SongId"", ""PlayedAt"", ""TotalPlays"", ""CreatedAt"", ""UpdatedAt"" - ) - SELECT - ""Id"", ""UserId"", ""SongId"", ""PlayedAt"", ""TotalPlays"", ""CreatedAt"", ""UpdatedAt"" - FROM tmp_playhistory_aggregated; - - DROP TABLE tmp_playhistory_aggregated; - "); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "TotalPlays", - table: "PlayHistory"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class Db_Improvement : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<int>( + name: "TotalPlays", + table: "PlayHistory", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(@" + CREATE TEMP TABLE tmp_playhistory_aggregated AS + SELECT + ( + SELECT ph2.""Id"" + FROM ""PlayHistory"" ph2 + WHERE ph2.""UserId"" = ph.""UserId"" + AND ph2.""SongId"" = ph.""SongId"" + ORDER BY ph2.""PlayedAt"" + LIMIT 1 + ) AS ""Id"", + ph.""UserId"", + ph.""SongId"", + MIN(ph.""PlayedAt"") AS ""PlayedAt"", + COUNT(*) AS ""TotalPlays"", + MIN(ph.""CreatedAt"") AS ""CreatedAt"", + MAX(ph.""UpdatedAt"") AS ""UpdatedAt"" + FROM ""PlayHistory"" ph + GROUP BY ph.""UserId"", ph.""SongId""; + + DELETE FROM ""PlayHistory"" + WHERE (""UserId"", ""SongId"") IN ( + SELECT ""UserId"", ""SongId"" FROM tmp_playhistory_aggregated + ); + + INSERT INTO ""PlayHistory"" ( + ""Id"", ""UserId"", ""SongId"", ""PlayedAt"", ""TotalPlays"", ""CreatedAt"", ""UpdatedAt"" + ) + SELECT + ""Id"", ""UserId"", ""SongId"", ""PlayedAt"", ""TotalPlays"", ""CreatedAt"", ""UpdatedAt"" + FROM tmp_playhistory_aggregated; + + DROP TABLE tmp_playhistory_aggregated; + "); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TotalPlays", + table: "PlayHistory"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.Designer.cs b/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.Designer.cs index 4933d0f..2ca4401 100644 --- a/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.Designer.cs @@ -1,179 +1,179 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250707113440_Add_Radio_Source")] - partial class Add_Radio_Source - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.RadioSource", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("RadioSources"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250707113440_Add_Radio_Source")] + partial class Add_Radio_Source + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.RadioSource", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("RadioSources"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.cs b/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.cs index efc2bba..4f58f5d 100644 --- a/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.cs +++ b/src/Infrastructure/Data/Migrations/20250707113440_Add_Radio_Source.cs @@ -1,49 +1,49 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class Add_Radio_Source : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "RadioSources", - columns: table => new - { - Id = table.Column<Guid>(type: "uuid", nullable: false), - Name = table.Column<string>(type: "text", nullable: false), - SourceUrl = table.Column<string>(type: "text", nullable: false), - CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RadioSources", x => x.Id); - }); - - migrationBuilder.Sql(""" - INSERT INTO "RadioSources" ("Id", "Name", "SourceUrl", "CreatedAt", "UpdatedAt") VALUES - (gen_random_uuid(), 'Wai FM', 'https://28153.live.streamtheworld.com/WAI_FM_IBANAAC.aac', now(), now()), - (gen_random_uuid(), 'Cats FM', 'https://s4.yesstreaming.net:7019/stream', now(), now()), - (gen_random_uuid(), 'Sarawak FM', 'https://28103.live.streamtheworld.com/SARAWAK_FMAAC.aac', now(), now()), - (gen_random_uuid(), 'Hitz FM', 'https://n09.rcs.revma.com/488kt4sbv4uvv?rj-ttl=5&rj-tok=AAABl-TNQ8MAAqxQIIxvj7gh5A', now(), now()), - (gen_random_uuid(), 'Traxx FM', 'https://22253.live.streamtheworld.com/TRAXX_FMAAC.aac', now(), now()), - (gen_random_uuid(), 'Klasik FM', 'https://22273.live.streamtheworld.com/RADIO_KLASIKAAC_SC', now(), now()), - (gen_random_uuid(), 'Hot FM', 'https://mediaprima.rastream.com/mediaprima-hotfm?awparams=companionads%3Afalse%3Btags%3Aradioactive%3Bstationid%3Amediaprima-hotfm&playerid=Hot%20FM_web&authtoken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvaWQiOiJsYXlsaW8iLCJpYXQiOjE1NzQxNTI5MjMsImV4cCI6MTU3NDIzOTMyM30.1xeZBOUhd1OeGsUnMEZgDdaLjKTrSrtxU3eSqLlZ5nE&aw_0_1st.lotame_segments=%5B%5D&lan=%5B%22ms%22%5D&setLanguage=true&listenerid=cae4395ba6c6a3a473e2af6af5f9f6fd', now(), now()), - (gen_random_uuid(), 'Sinar FM', 'https://n13.rcs.revma.com/azatk0tbv4uvv?rj-ttl=5&rj-tok=AAABl-TO6SMAk3NBussdDeaJpA', now(), now()); - """); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "RadioSources"); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class Add_Radio_Source : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RadioSources", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Name = table.Column<string>(type: "text", nullable: false), + SourceUrl = table.Column<string>(type: "text", nullable: false), + CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RadioSources", x => x.Id); + }); + + migrationBuilder.Sql(""" + INSERT INTO "RadioSources" ("Id", "Name", "SourceUrl", "CreatedAt", "UpdatedAt") VALUES + (gen_random_uuid(), 'Wai FM', 'https://28153.live.streamtheworld.com/WAI_FM_IBANAAC.aac', now(), now()), + (gen_random_uuid(), 'Cats FM', 'https://s4.yesstreaming.net:7019/stream', now(), now()), + (gen_random_uuid(), 'Sarawak FM', 'https://28103.live.streamtheworld.com/SARAWAK_FMAAC.aac', now(), now()), + (gen_random_uuid(), 'Hitz FM', 'https://n09.rcs.revma.com/488kt4sbv4uvv?rj-ttl=5&rj-tok=AAABl-TNQ8MAAqxQIIxvj7gh5A', now(), now()), + (gen_random_uuid(), 'Traxx FM', 'https://22253.live.streamtheworld.com/TRAXX_FMAAC.aac', now(), now()), + (gen_random_uuid(), 'Klasik FM', 'https://22273.live.streamtheworld.com/RADIO_KLASIKAAC_SC', now(), now()), + (gen_random_uuid(), 'Hot FM', 'https://mediaprima.rastream.com/mediaprima-hotfm?awparams=companionads%3Afalse%3Btags%3Aradioactive%3Bstationid%3Amediaprima-hotfm&playerid=Hot%20FM_web&authtoken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvaWQiOiJsYXlsaW8iLCJpYXQiOjE1NzQxNTI5MjMsImV4cCI6MTU3NDIzOTMyM30.1xeZBOUhd1OeGsUnMEZgDdaLjKTrSrtxU3eSqLlZ5nE&aw_0_1st.lotame_segments=%5B%5D&lan=%5B%22ms%22%5D&setLanguage=true&listenerid=cae4395ba6c6a3a473e2af6af5f9f6fd', now(), now()), + (gen_random_uuid(), 'Sinar FM', 'https://n13.rcs.revma.com/azatk0tbv4uvv?rj-ttl=5&rj-tok=AAABl-TO6SMAk3NBussdDeaJpA', now(), now()); + """); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RadioSources"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.Designer.cs b/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.Designer.cs index a0164b0..a3de45f 100644 --- a/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.Designer.cs @@ -1,182 +1,182 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250713085102_Update_Radio_Source")] - partial class Update_Radio_Source - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.RadioSource", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsActive") - .HasColumnType("boolean"); - - b.Property<string>("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("RadioSources"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .IsRequired() - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250713085102_Update_Radio_Source")] + partial class Update_Radio_Source + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.RadioSource", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("RadioSources"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.cs b/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.cs index cb7debf..3b7e9d9 100644 --- a/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.cs +++ b/src/Infrastructure/Data/Migrations/20250713085102_Update_Radio_Source.cs @@ -1,29 +1,29 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class Update_Radio_Source : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn<bool>( - name: "IsActive", - table: "RadioSources", - type: "boolean", - nullable: false, - defaultValue: false); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsActive", - table: "RadioSources"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class Update_Radio_Source : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<bool>( + name: "IsActive", + table: "RadioSources", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsActive", + table: "RadioSources"); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.Designer.cs b/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.Designer.cs index 044a2ea..3ccec74 100644 --- a/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.Designer.cs @@ -1,181 +1,181 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250714030135_Display_null_fixes")] - partial class Display_null_fixes - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.6") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.RadioSource", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsActive") - .HasColumnType("boolean"); - - b.Property<string>("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("RadioSources"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250714030135_Display_null_fixes")] + partial class Display_null_fixes + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.RadioSource", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("RadioSources"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.cs b/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.cs index d353146..85a1d0d 100644 --- a/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.cs +++ b/src/Infrastructure/Data/Migrations/20250714030135_Display_null_fixes.cs @@ -1,36 +1,36 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class Display_null_fixes : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<string>( - name: "DisplayName", - table: "Users", - type: "text", - nullable: true, - oldClrType: typeof(string), - oldType: "text"); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<string>( - name: "DisplayName", - table: "Users", - type: "text", - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "text", - oldNullable: true); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class Display_null_fixes : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<string>( + name: "DisplayName", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<string>( + name: "DisplayName", + table: "Users", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.Designer.cs b/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.Designer.cs index ec2214f..92c0afe 100644 --- a/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.Designer.cs +++ b/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.Designer.cs @@ -1,181 +1,181 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - [Migration("20250910100912_Add UpdatedAt Nullable Support")] - partial class AddUpdatedAtNullableSupport - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.RadioSource", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsActive") - .HasColumnType("boolean"); - - b.Property<string>("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("RadioSources"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + [Migration("20250910100912_Add UpdatedAt Nullable Support")] + partial class AddUpdatedAtNullableSupport + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.RadioSource", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("RadioSources"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.cs b/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.cs index 3a6a4c1..1c1d771 100644 --- a/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.cs +++ b/src/Infrastructure/Data/Migrations/20250910100912_Add UpdatedAt Nullable Support.cs @@ -1,91 +1,91 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Infrastructure.Data.Migrations -{ - /// <inheritdoc /> - public partial class AddUpdatedAtNullableSupport : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: true, - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: true, - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "RadioSources", - type: "timestamp with time zone", - nullable: true, - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: true, - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone"); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Users", - type: "timestamp with time zone", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone", - oldNullable: true); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "Songs", - type: "timestamp with time zone", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone", - oldNullable: true); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "RadioSources", - type: "timestamp with time zone", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone", - oldNullable: true); - - migrationBuilder.AlterColumn<DateTimeOffset>( - name: "UpdatedAt", - table: "PlayHistory", - type: "timestamp with time zone", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), - oldClrType: typeof(DateTimeOffset), - oldType: "timestamp with time zone", - oldNullable: true); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + /// <inheritdoc /> + public partial class AddUpdatedAtNullableSupport : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "RadioSources", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "Songs", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "RadioSources", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn<DateTimeOffset>( + name: "UpdatedAt", + table: "PlayHistory", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + } + } +} diff --git a/src/Infrastructure/Data/Migrations/DiscordBotContextModelSnapshot.cs b/src/Infrastructure/Data/Migrations/DiscordBotContextModelSnapshot.cs index d0dc010..ec5fc87 100644 --- a/src/Infrastructure/Data/Migrations/DiscordBotContextModelSnapshot.cs +++ b/src/Infrastructure/Data/Migrations/DiscordBotContextModelSnapshot.cs @@ -1,178 +1,178 @@ -// <auto-generated /> -using System; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Data.Migrations -{ - [DbContext(typeof(DiscordBotContext))] - partial class DiscordBotContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTimeOffset>("PlayedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("SongId") - .HasColumnType("uuid"); - - b.Property<int>("TotalPlays") - .HasColumnType("integer"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<decimal>("UserId") - .HasColumnType("numeric(20,0)"); - - b.HasKey("Id"); - - b.HasIndex("SongId"); - - b.HasIndex("UserId"); - - b.ToTable("PlayHistory"); - }); - - modelBuilder.Entity("Domain.Entities.RadioSource", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsActive") - .HasColumnType("boolean"); - - b.Property<string>("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("RadioSources"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<bool>("IsBlacklisted") - .HasColumnType("boolean"); - - b.Property<string>("SourceUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceUrl") - .IsUnique(); - - b.ToTable("Songs"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Property<decimal>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)"); - - b.Property<DateTimeOffset>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("DisplayName") - .HasColumnType("text"); - - b.Property<int>("TotalSongsPlayed") - .HasColumnType("integer"); - - b.Property<DateTimeOffset?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Domain.Entities.PlayHistory", b => - { - b.HasOne("Domain.Entities.Song", "Song") - .WithMany("PlayHistories") - .HasForeignKey("SongId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Domain.Entities.User", "User") - .WithMany("PlayHistories") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Song"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Domain.Entities.Song", b => - { - b.Navigation("PlayHistories"); - }); - - modelBuilder.Entity("Domain.Entities.User", b => - { - b.Navigation("PlayHistories"); - }); -#pragma warning restore 612, 618 - } - } -} +// <auto-generated /> +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(DiscordBotContext))] + partial class DiscordBotContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset>("PlayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("SongId") + .HasColumnType("uuid"); + + b.Property<int>("TotalPlays") + .HasColumnType("integer"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<decimal>("UserId") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("SongId"); + + b.HasIndex("UserId"); + + b.ToTable("PlayHistory"); + }); + + modelBuilder.Entity("Domain.Entities.RadioSource", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsActive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("RadioSources"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("IsBlacklisted") + .HasColumnType("boolean"); + + b.Property<string>("SourceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceUrl") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property<decimal>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<int>("TotalSongsPlayed") + .HasColumnType("integer"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.PlayHistory", b => + { + b.HasOne("Domain.Entities.Song", "Song") + .WithMany("PlayHistories") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("PlayHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Song", b => + { + b.Navigation("PlayHistories"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("PlayHistories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 760bf53..829bfcc 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -1,27 +1,27 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <ItemGroup> - <ProjectReference Include="..\Application\Application.csproj"/> - <ProjectReference Include="..\Domain\Domain.csproj"/> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> - <PackageReference Include="NetCord" /> - <PackageReference Include="NetCord.Hosting" /> - <PackageReference Include="NetCord.Hosting.Services" /> - <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> - <PackageReference Include="NAudio" /> - <PackageReference Include="SoundCloudExplode" /> - <PackageReference Include="SoundTouch.Net.NAudioSupport" /> - <PackageReference Include="YoutubeDLSharp" /> - <PackageReference Include="YoutubeExplode" /> - </ItemGroup> - -</Project> +<Project Sdk="Microsoft.NET.Sdk"> + + <ItemGroup> + <ProjectReference Include="..\Application\Application.csproj"/> + <ProjectReference Include="..\Domain\Domain.csproj"/> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.EntityFrameworkCore" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> + <PackageReference Include="NetCord" /> + <PackageReference Include="NetCord.Hosting" /> + <PackageReference Include="NetCord.Hosting.Services" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> + <PackageReference Include="NAudio" /> + <PackageReference Include="SoundCloudExplode" /> + <PackageReference Include="SoundTouch.Net.NAudioSupport" /> + <PackageReference Include="YoutubeDLSharp" /> + <PackageReference Include="YoutubeExplode" /> + </ItemGroup> + +</Project> diff --git a/src/Infrastructure/Interaction/NetCordInteraction.cs b/src/Infrastructure/Interaction/NetCordInteraction.cs index 1465b7c..db13d58 100644 --- a/src/Infrastructure/Interaction/NetCordInteraction.cs +++ b/src/Infrastructure/Interaction/NetCordInteraction.cs @@ -1,148 +1,148 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Domain.Common; -using Infrastructure.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NetCord.Rest; -using NetCord.Services.ComponentInteractions; -using YoutubeExplode; -using YoutubeExplode.Common; - -namespace Infrastructure.Interaction; - -public class NetCordInteraction( - ILogger<NetCordInteraction> logger, - IGuildMusicService guildMusicService, - IBlacklistService blacklistService, - YoutubeClient youtubeClient, - [FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule<StringMenuInteractionContext> -{ - [ComponentInteraction(Constants.CustomIds.Play)] - public async Task<string> Play() - { - var error = EvaluateChecking(); - if (error != null) - { - return error; - } - - logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, - Context.Guild?.Id); - - var selectedValue = Context.SelectedValues[0]; - - string message; - if (!Guid.TryParse(selectedValue, out _)) - { - if (await blacklistService.IsBlacklistedAsync(selectedValue)) - { - return "The requested song is blacklisted and cannot be played."; - } - - var title = await youtubeService.GetVideoTitleAsync(selectedValue, CancellationToken.None); - message = $"Added {title} to the queue!"; - } - else - { - message = "Added radio source to the queue!"; - } - - var playRequest = new PlayRequest<StringMenuInteractionContext> - { - Context = Context, - Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage), - }; - guildMusicService.Enqueue(Context.Guild!.Id, playRequest); - - return message; - } - - [ComponentInteraction(Constants.CustomIds.PlayListPlay)] - public async Task<string> PlayPlaylist() - { - var error = EvaluateChecking(); - if (error != null) - { - return error; - } - - logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, - Context.Guild?.Id); - - var videos = await youtubeClient.Playlists.GetVideosAsync(Context.SelectedValues[0]); - - var blacklistedUrls = (await blacklistService.GetBlacklistedSongsAsync()) - .Select(s => s.SourceUrl) - .ToHashSet(); - - var added = 0; - foreach (var video in videos) - { - if (blacklistedUrls.Contains(video.Url)) - { - continue; - } - - var playRequest = new PlayRequest<StringMenuInteractionContext> - { - Context = Context, - Callbacks = async message => await RespondAsyncCallback(message), - VideoTitle = video.Title, - VideoUrl = video.Url - }; - guildMusicService.Enqueue(Context.Guild!.Id, playRequest); - added++; - } - - return added > 0 - ? "Playlist Added to the queue!" - : "All songs in the playlist are blacklisted; nothing was added."; - } - - private string? EvaluateChecking() - { - if (Context.Guild is null) - { - return "This command can only be used in a server."; - } - - if (!CheckMessageExpiration()) - { - return "This interaction has expired. Please use the play command again."; - } - - if (!NotInVoiceChannel()) - { - return "You must be in a voice channel to use this command."; - } - - if (!NotDeafened()) - { - return "You must not be deafened to use this command."; - } - - return null; - } - - private bool NotInVoiceChannel() - { - return Context.Guild!.VoiceStates.TryGetValue(Context.User.Id, out _); - } - - private bool NotDeafened() - { - var voiceState = Context.Guild!.VoiceStates[Context.User.Id]; - return !(voiceState.IsDeafened || voiceState.IsSelfDeafened); - } - - private bool CheckMessageExpiration() - { - var messageCreationTime = Context.Message.CreatedAt; - var interactionTime = Context.Interaction.CreatedAt; - var timeDifference = interactionTime - messageCreationTime; - return timeDifference.TotalDays <= 2; - } - - private Task<InteractionCallbackResponse> RespondAsyncCallback(string message) => RespondAsync(InteractionCallback.Message(message))!; -} +using Application.DTOs; +using Application.Interfaces.Services; +using Domain.Common; +using Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetCord.Rest; +using NetCord.Services.ComponentInteractions; +using YoutubeExplode; +using YoutubeExplode.Common; + +namespace Infrastructure.Interaction; + +public class NetCordInteraction( + ILogger<NetCordInteraction> logger, + IGuildMusicService guildMusicService, + IBlacklistService blacklistService, + YoutubeClient youtubeClient, + [FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule<StringMenuInteractionContext> +{ + [ComponentInteraction(Constants.CustomIds.Play)] + public async Task<string> Play() + { + var error = EvaluateChecking(); + if (error != null) + { + return error; + } + + logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, + Context.Guild?.Id); + + var selectedValue = Context.SelectedValues[0]; + + string message; + if (!Guid.TryParse(selectedValue, out _)) + { + if (await blacklistService.IsBlacklistedAsync(selectedValue)) + { + return "The requested song is blacklisted and cannot be played."; + } + + var title = await youtubeService.GetVideoTitleAsync(selectedValue, CancellationToken.None); + message = $"Added {title} to the queue!"; + } + else + { + message = "Added radio source to the queue!"; + } + + var playRequest = new PlayRequest<StringMenuInteractionContext> + { + Context = Context, + Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage), + }; + guildMusicService.Enqueue(Context.Guild!.Id, playRequest); + + return message; + } + + [ComponentInteraction(Constants.CustomIds.PlayListPlay)] + public async Task<string> PlayPlaylist() + { + var error = EvaluateChecking(); + if (error != null) + { + return error; + } + + logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, + Context.Guild?.Id); + + var videos = await youtubeClient.Playlists.GetVideosAsync(Context.SelectedValues[0]); + + var blacklistedUrls = (await blacklistService.GetBlacklistedSongsAsync()) + .Select(s => s.SourceUrl) + .ToHashSet(); + + var added = 0; + foreach (var video in videos) + { + if (blacklistedUrls.Contains(video.Url)) + { + continue; + } + + var playRequest = new PlayRequest<StringMenuInteractionContext> + { + Context = Context, + Callbacks = async message => await RespondAsyncCallback(message), + VideoTitle = video.Title, + VideoUrl = video.Url + }; + guildMusicService.Enqueue(Context.Guild!.Id, playRequest); + added++; + } + + return added > 0 + ? "Playlist Added to the queue!" + : "All songs in the playlist are blacklisted; nothing was added."; + } + + private string? EvaluateChecking() + { + if (Context.Guild is null) + { + return "This command can only be used in a server."; + } + + if (!CheckMessageExpiration()) + { + return "This interaction has expired. Please use the play command again."; + } + + if (!NotInVoiceChannel()) + { + return "You must be in a voice channel to use this command."; + } + + if (!NotDeafened()) + { + return "You must not be deafened to use this command."; + } + + return null; + } + + private bool NotInVoiceChannel() + { + return Context.Guild!.VoiceStates.TryGetValue(Context.User.Id, out _); + } + + private bool NotDeafened() + { + var voiceState = Context.Guild!.VoiceStates[Context.User.Id]; + return !(voiceState.IsDeafened || voiceState.IsSelfDeafened); + } + + private bool CheckMessageExpiration() + { + var messageCreationTime = Context.Message.CreatedAt; + var interactionTime = Context.Interaction.CreatedAt; + var timeDifference = interactionTime - messageCreationTime; + return timeDifference.TotalDays <= 2; + } + + private Task<InteractionCallbackResponse> RespondAsyncCallback(string message) => RespondAsync(InteractionCallback.Message(message))!; +} diff --git a/src/Infrastructure/Services/AssemblyMarker.cs b/src/Infrastructure/Services/AssemblyMarker.cs index 63e7102..7bf597b 100644 --- a/src/Infrastructure/Services/AssemblyMarker.cs +++ b/src/Infrastructure/Services/AssemblyMarker.cs @@ -1,3 +1,3 @@ -namespace Infrastructure.Services; - +namespace Infrastructure.Services; + public sealed class AssemblyMarker; \ No newline at end of file diff --git a/src/Infrastructure/Services/AudioPlayerService.cs b/src/Infrastructure/Services/AudioPlayerService.cs index 48f447b..ba456fa 100644 --- a/src/Infrastructure/Services/AudioPlayerService.cs +++ b/src/Infrastructure/Services/AudioPlayerService.cs @@ -1,174 +1,174 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using NetCord.Gateway; -using NetCord.Gateway.Voice; -using NetCord.Logging; -using NetCord.Rest; -using NetCord.Services.ComponentInteractions; - -namespace Infrastructure.Services; - -public class AudioPlayerService( - INativePlaceMusicProcessorService ffmpegProcessService, - IServiceProvider serviceProvider, - ILogger<AudioPlayerService> logger) : INetCordAudioPlayerService -{ - private VoiceClient? _voiceClient; - private GatewayClient? _gatewayClient; - private ulong? _guildId; - - public async Task<TrackPlayResult> PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken) - { - if (request is not PlayRequest<StringMenuInteractionContext> track) - { - throw new ArgumentException( - $"Invalid request type. Expected {typeof(PlayRequest<StringMenuInteractionContext>)}", - nameof(request)); - } - - var context = track.Context; - var guild = context.Guild; - if (guild is null || !guild.VoiceStates.TryGetValue(context.User.Id, out var voiceState)) - { - return TrackPlayResult.NotInVoiceChannel; - } - - try - { - if (_voiceClient is null) - { - await ConnectAsync(context.Client, guild.Id, voiceState.ChannelId.GetValueOrDefault(), - cancellationToken); - } - - using var scope = serviceProvider.CreateScope(); - var streamService = scope.ServiceProvider.GetRequiredKeyedService<IStreamService>(nameof(YoutubeService)); - var radioSourceService = scope.ServiceProvider.GetRequiredService<IRadioSourceService>(); - var statisticsService = scope.ServiceProvider.GetRequiredService<IStatisticsService>(); - - var selectedValue = track.VideoUrl ?? context.SelectedValues[0]; - var (sourceUrl, song) = await ResolveSourceAsync(selectedValue, track, context.User.Id, - streamService, radioSourceService, cancellationToken); - - var process = await ffmpegProcessService.CreateStreamAsync(sourceUrl, cancellationToken); - try - { - await statisticsService.LogSongPlayAsync(context.User.Id, context.User.Username, - context.User.GlobalName ?? string.Empty, song); - - var voiceClient = _voiceClient; - if (voiceClient is null) - { - logger.LogError("Voice client is no longer connected"); - return TrackPlayResult.Failed; - } - - var outStream = voiceClient.CreateVoiceStream(); - var opusStream = new OpusEncodeStream(outStream, PcmFormat.Short, VoiceChannels.Stereo, - OpusApplication.Audio); - - await process.StandardOutput.BaseStream.CopyToAsync(opusStream, cancellationToken); - // Flush to make sure all the data has been sent and to indicate to Discord that we have finished sending - await opusStream.FlushAsync(cancellationToken); - - await process.WaitForExitAsync(cancellationToken); - return process.ExitCode == 0 ? TrackPlayResult.Completed : TrackPlayResult.Failed; - } - finally - { - await ffmpegProcessService.StopCurrentProcessAsync(); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return TrackPlayResult.Skipped; - } - } - - public async Task DisconnectAsync() - { - var voiceClient = _voiceClient; - var gatewayClient = _gatewayClient; - var guildId = _guildId; - _voiceClient = null; - _gatewayClient = null; - _guildId = null; - - try - { - if (gatewayClient is not null && guildId is not null) - { - await gatewayClient.UpdateVoiceStateAsync(new VoiceStateProperties(guildId.Value, null)); - } - - if (voiceClient is not null) - { - await voiceClient.CloseAsync(); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Error while disconnecting voice client"); - } - } - - private async Task ConnectAsync(GatewayClient client, ulong guildId, ulong channelId, - CancellationToken cancellationToken) - { - var voiceClient = await client.JoinVoiceChannelAsync( - guildId, - channelId, - new VoiceClientConfiguration - { - Logger = new ConsoleLogger(), - }, cancellationToken: cancellationToken); - - voiceClient.Disconnect += _ => - { - logger.LogInformation("Voice client disconnected"); - // Drop the cached client so the next track triggers a fresh join. - _voiceClient = null; - return default; - }; - - await voiceClient.StartAsync(cancellationToken); - await voiceClient.EnterSpeakingStateAsync( - new SpeakingProperties(SpeakingFlags.Microphone), - cancellationToken: cancellationToken); - - _voiceClient = voiceClient; - _gatewayClient = client; - _guildId = guildId; - } - - private static async Task<(string SourceUrl, SongDtoBase Song)> ResolveSourceAsync( - string selectedValue, - PlayRequest track, - ulong userId, - IStreamService streamService, - IRadioSourceService radioSourceService, - CancellationToken cancellationToken) - { - if (Guid.TryParse(selectedValue, out var radioId)) - { - var radio = await radioSourceService.GetRadioSourceByIdAsync(radioId, cancellationToken); - return (radio.SourceUrl, new SongDtoBase - { - Url = radio.SourceUrl, - Title = radio.Name, - UserId = userId - }); - } - - var sourceUrl = await streamService.GetAudioStreamUrlAsync(selectedValue, cancellationToken); - var title = track.VideoTitle ?? await streamService.GetVideoTitleAsync(selectedValue, cancellationToken); - return (sourceUrl, new SongDtoBase - { - Url = selectedValue, - Title = title, - UserId = userId - }); - } -} +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetCord.Gateway; +using NetCord.Gateway.Voice; +using NetCord.Logging; +using NetCord.Rest; +using NetCord.Services.ComponentInteractions; + +namespace Infrastructure.Services; + +public class AudioPlayerService( + INativePlaceMusicProcessorService ffmpegProcessService, + IServiceProvider serviceProvider, + ILogger<AudioPlayerService> logger) : INetCordAudioPlayerService +{ + private VoiceClient? _voiceClient; + private GatewayClient? _gatewayClient; + private ulong? _guildId; + + public async Task<TrackPlayResult> PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken) + { + if (request is not PlayRequest<StringMenuInteractionContext> track) + { + throw new ArgumentException( + $"Invalid request type. Expected {typeof(PlayRequest<StringMenuInteractionContext>)}", + nameof(request)); + } + + var context = track.Context; + var guild = context.Guild; + if (guild is null || !guild.VoiceStates.TryGetValue(context.User.Id, out var voiceState)) + { + return TrackPlayResult.NotInVoiceChannel; + } + + try + { + if (_voiceClient is null) + { + await ConnectAsync(context.Client, guild.Id, voiceState.ChannelId.GetValueOrDefault(), + cancellationToken); + } + + using var scope = serviceProvider.CreateScope(); + var streamService = scope.ServiceProvider.GetRequiredKeyedService<IStreamService>(nameof(YoutubeService)); + var radioSourceService = scope.ServiceProvider.GetRequiredService<IRadioSourceService>(); + var statisticsService = scope.ServiceProvider.GetRequiredService<IStatisticsService>(); + + var selectedValue = track.VideoUrl ?? context.SelectedValues[0]; + var (sourceUrl, song) = await ResolveSourceAsync(selectedValue, track, context.User.Id, + streamService, radioSourceService, cancellationToken); + + var process = await ffmpegProcessService.CreateStreamAsync(sourceUrl, cancellationToken); + try + { + await statisticsService.LogSongPlayAsync(context.User.Id, context.User.Username, + context.User.GlobalName ?? string.Empty, song); + + var voiceClient = _voiceClient; + if (voiceClient is null) + { + logger.LogError("Voice client is no longer connected"); + return TrackPlayResult.Failed; + } + + var outStream = voiceClient.CreateVoiceStream(); + var opusStream = new OpusEncodeStream(outStream, PcmFormat.Short, VoiceChannels.Stereo, + OpusApplication.Audio); + + await process.StandardOutput.BaseStream.CopyToAsync(opusStream, cancellationToken); + // Flush to make sure all the data has been sent and to indicate to Discord that we have finished sending + await opusStream.FlushAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode == 0 ? TrackPlayResult.Completed : TrackPlayResult.Failed; + } + finally + { + await ffmpegProcessService.StopCurrentProcessAsync(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return TrackPlayResult.Skipped; + } + } + + public async Task DisconnectAsync() + { + var voiceClient = _voiceClient; + var gatewayClient = _gatewayClient; + var guildId = _guildId; + _voiceClient = null; + _gatewayClient = null; + _guildId = null; + + try + { + if (gatewayClient is not null && guildId is not null) + { + await gatewayClient.UpdateVoiceStateAsync(new VoiceStateProperties(guildId.Value, null)); + } + + if (voiceClient is not null) + { + await voiceClient.CloseAsync(); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error while disconnecting voice client"); + } + } + + private async Task ConnectAsync(GatewayClient client, ulong guildId, ulong channelId, + CancellationToken cancellationToken) + { + var voiceClient = await client.JoinVoiceChannelAsync( + guildId, + channelId, + new VoiceClientConfiguration + { + Logger = new ConsoleLogger(), + }, cancellationToken: cancellationToken); + + voiceClient.Disconnect += _ => + { + logger.LogInformation("Voice client disconnected"); + // Drop the cached client so the next track triggers a fresh join. + _voiceClient = null; + return default; + }; + + await voiceClient.StartAsync(cancellationToken); + await voiceClient.EnterSpeakingStateAsync( + new SpeakingProperties(SpeakingFlags.Microphone), + cancellationToken: cancellationToken); + + _voiceClient = voiceClient; + _gatewayClient = client; + _guildId = guildId; + } + + private static async Task<(string SourceUrl, SongDtoBase Song)> ResolveSourceAsync( + string selectedValue, + PlayRequest track, + ulong userId, + IStreamService streamService, + IRadioSourceService radioSourceService, + CancellationToken cancellationToken) + { + if (Guid.TryParse(selectedValue, out var radioId)) + { + var radio = await radioSourceService.GetRadioSourceByIdAsync(radioId, cancellationToken); + return (radio.SourceUrl, new SongDtoBase + { + Url = radio.SourceUrl, + Title = radio.Name, + UserId = userId + }); + } + + var sourceUrl = await streamService.GetAudioStreamUrlAsync(selectedValue, cancellationToken); + var title = track.VideoTitle ?? await streamService.GetVideoTitleAsync(selectedValue, cancellationToken); + return (sourceUrl, new SongDtoBase + { + Url = selectedValue, + Title = title, + UserId = userId + }); + } +} diff --git a/src/Infrastructure/Services/BlacklistService.cs b/src/Infrastructure/Services/BlacklistService.cs index 7e1175e..73dfcc8 100644 --- a/src/Infrastructure/Services/BlacklistService.cs +++ b/src/Infrastructure/Services/BlacklistService.cs @@ -1,87 +1,87 @@ -using Application.Interfaces.Services; -using Domain.Entities; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; - -namespace Infrastructure.Services; - -public class BlacklistService(DiscordBotContext context): IBlacklistService -{ - public async Task<bool> AddToBlacklistAsync(string sourceUrl) - { - if (string.IsNullOrEmpty(sourceUrl)) - { - throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); - } - - var song = await context.Songs.FirstOrDefaultAsync(b => b.SourceUrl == sourceUrl); - if (song is null) - { - return false; - } - - song = Song.MarkAsBlacklisted(song, true); - context.Songs.Update(song); - - await context.SaveChangesAsync(); - return true; - } - - /// <summary> - /// Removes a song from the blacklist base on title. - /// Use contains to match the title. - /// </summary> - /// <param name="title"></param> - /// <returns></returns> - public async Task<bool> RemoveFromBlacklistAsync(string title) - { - if (string.IsNullOrEmpty(title)) - { - throw new ArgumentException("Title cannot be null or empty.", nameof(title)); - } - - var pattern = $"%{EscapeLikePattern(title.ToLower())}%"; - var song = await context.Songs.FirstOrDefaultAsync(b => - EF.Functions.Like(b.Title.ToLower(), pattern, "\\")); - if (song is null) - { - return false; - } - - song = Song.MarkAsBlacklisted(song, false); - context.Songs.Update(song); - - await context.SaveChangesAsync(); - return true; - } - - /// <summary> - /// Checks if a song is blacklisted based on its source URL. - /// </summary> - /// <param name="sourceUrl"></param> - /// <returns></returns> - /// <exception cref="ArgumentException"></exception> - public Task<bool> IsBlacklistedAsync(string sourceUrl) - { - if (string.IsNullOrEmpty(sourceUrl)) - { - throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); - } - - return context.Songs.AnyAsync(b => b.SourceUrl == sourceUrl && b.IsBlacklisted); - } - - // Get all blacklisted songs - public Task<List<Song>> GetBlacklistedSongsAsync() - { - return context.Songs.Where(b => b.IsBlacklisted).ToListAsync(); - } - - private static string EscapeLikePattern(string input) - { - return input - .Replace("\\", "\\\\") - .Replace("%", "\\%") - .Replace("_", "\\_"); - } -} +using Application.Interfaces.Services; +using Domain.Entities; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Services; + +public class BlacklistService(DiscordBotContext context): IBlacklistService +{ + public async Task<bool> AddToBlacklistAsync(string sourceUrl) + { + if (string.IsNullOrEmpty(sourceUrl)) + { + throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); + } + + var song = await context.Songs.FirstOrDefaultAsync(b => b.SourceUrl == sourceUrl); + if (song is null) + { + return false; + } + + song = Song.MarkAsBlacklisted(song, true); + context.Songs.Update(song); + + await context.SaveChangesAsync(); + return true; + } + + /// <summary> + /// Removes a song from the blacklist base on title. + /// Use contains to match the title. + /// </summary> + /// <param name="title"></param> + /// <returns></returns> + public async Task<bool> RemoveFromBlacklistAsync(string title) + { + if (string.IsNullOrEmpty(title)) + { + throw new ArgumentException("Title cannot be null or empty.", nameof(title)); + } + + var pattern = $"%{EscapeLikePattern(title.ToLower())}%"; + var song = await context.Songs.FirstOrDefaultAsync(b => + EF.Functions.Like(b.Title.ToLower(), pattern, "\\")); + if (song is null) + { + return false; + } + + song = Song.MarkAsBlacklisted(song, false); + context.Songs.Update(song); + + await context.SaveChangesAsync(); + return true; + } + + /// <summary> + /// Checks if a song is blacklisted based on its source URL. + /// </summary> + /// <param name="sourceUrl"></param> + /// <returns></returns> + /// <exception cref="ArgumentException"></exception> + public Task<bool> IsBlacklistedAsync(string sourceUrl) + { + if (string.IsNullOrEmpty(sourceUrl)) + { + throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); + } + + return context.Songs.AnyAsync(b => b.SourceUrl == sourceUrl && b.IsBlacklisted); + } + + // Get all blacklisted songs + public Task<List<Song>> GetBlacklistedSongsAsync() + { + return context.Songs.Where(b => b.IsBlacklisted).ToListAsync(); + } + + private static string EscapeLikePattern(string input) + { + return input + .Replace("\\", "\\\\") + .Replace("%", "\\%") + .Replace("_", "\\_"); + } +} diff --git a/src/Infrastructure/Services/FfmpegProcessService.cs b/src/Infrastructure/Services/FfmpegProcessService.cs index 169a0c4..a076045 100644 --- a/src/Infrastructure/Services/FfmpegProcessService.cs +++ b/src/Infrastructure/Services/FfmpegProcessService.cs @@ -1,217 +1,217 @@ -using System.Diagnostics; -using Application.Interfaces.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Services; - -public class FfmpegProcessService(ILogger<FfmpegProcessService> logger, IConfiguration configuration) - : INativePlaceMusicProcessorService, IDisposable -{ - private Process? _ffmpegProcess; - private bool _disposed; - private readonly Lock _processLock = new(); - private readonly string _ffmpegPath = configuration["Ffmpeg:Path"] ?? "/usr/bin/ffmpeg"; - - public async Task<Process> CreateStreamAsync(string audioUrl, CancellationToken cancellationToken) - { - // Make sure any previous process is gone before starting a new one. - await StopCurrentProcessAsync(); - cancellationToken.ThrowIfCancellationRequested(); - - var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = _ffmpegPath, - Arguments = - $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 -i \"{audioUrl}\" -f s16le -ar 48000 -ac 2 -bufsize 120k pipe:1", - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - EnableRaisingEvents = true - }; - - process.ErrorDataReceived += (_, e) => - { - if (string.IsNullOrEmpty(e.Data)) return; - - var level = e.Data.Contains("error", StringComparison.OrdinalIgnoreCase) || - e.Data.Contains("failed", StringComparison.OrdinalIgnoreCase) - ? LogLevel.Error - : e.Data.Contains("warning", StringComparison.OrdinalIgnoreCase) - ? LogLevel.Warning - : LogLevel.Debug; - - logger.Log(level, "FFmpeg: {Message}", e.Data); - }; - - try - { - if (!process.Start()) - { - throw new InvalidOperationException("Failed to start ffmpeg process."); - } - - process.BeginErrorReadLine(); - - logger.LogInformation("FFmpeg process started for URL: {AudioUrl} (PID: {ProcessId})", - audioUrl, process.Id); - } - catch - { - process.Dispose(); - throw; - } - - lock (_processLock) - { - _ffmpegProcess = process; - } - - return process; - } - - public async Task StopCurrentProcessAsync() - { - Process? process; - lock (_processLock) - { - process = _ffmpegProcess; - _ffmpegProcess = null; - } - - if (process is null) return; - - await TerminateProcessAsync(process); - } - - private async Task TerminateProcessAsync(Process process) - { - try - { - try - { - if (process.HasExited) - { - logger.LogDebug("FFmpeg process already exited (PID: {ProcessId})", process.Id); - return; - } - } - catch (InvalidOperationException) - { - // Process was never started or already disposed - return; - } - - logger.LogInformation("Terminating FFmpeg process (PID: {ProcessId})...", process.Id); - - // Try graceful shutdown first - try - { - if (!process.HasExited) - { - process.StandardInput.WriteLine("q"); - process.StandardInput.Close(); - } - } - catch (InvalidOperationException) - { - // Process might have exited between checks - } - - if (await WaitForExitAsync(process, TimeSpan.FromMilliseconds(1500))) - { - logger.LogInformation("FFmpeg process terminated gracefully (PID: {ProcessId})", process.Id); - return; - } - - // Force kill if still running - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await WaitForExitAsync(process, TimeSpan.FromMilliseconds(3000)); - logger.LogInformation("FFmpeg process killed (PID: {ProcessId})", process.Id); - } - } - catch (InvalidOperationException) - { - logger.LogDebug("Process exited before kill command"); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error terminating FFmpeg process"); - } - finally - { - try - { - process.Dispose(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error disposing FFmpeg process"); - } - } - } - - private static async Task<bool> WaitForExitAsync(Process process, TimeSpan timeout) - { - using var cts = new CancellationTokenSource(timeout); - try - { - await process.WaitForExitAsync(cts.Token); - return true; - } - catch (OperationCanceledException) - { - return false; - } - } - - public void Dispose() - { - if (_disposed) return; - - Process? process; - lock (_processLock) - { - if (_disposed) return; - _disposed = true; - process = _ffmpegProcess; - _ffmpegProcess = null; - } - - if (process is null) return; - - // Best-effort synchronous kill during host shutdown. - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error killing FFmpeg process during dispose"); - } - finally - { - try - { - process.Dispose(); - } - catch - { - // ignored - } - } - } -} +using System.Diagnostics; +using Application.Interfaces.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +public class FfmpegProcessService(ILogger<FfmpegProcessService> logger, IConfiguration configuration) + : INativePlaceMusicProcessorService, IDisposable +{ + private Process? _ffmpegProcess; + private bool _disposed; + private readonly Lock _processLock = new(); + private readonly string _ffmpegPath = configuration["Ffmpeg:Path"] ?? "/usr/bin/ffmpeg"; + + public async Task<Process> CreateStreamAsync(string audioUrl, CancellationToken cancellationToken) + { + // Make sure any previous process is gone before starting a new one. + await StopCurrentProcessAsync(); + cancellationToken.ThrowIfCancellationRequested(); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _ffmpegPath, + Arguments = + $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 -i \"{audioUrl}\" -f s16le -ar 48000 -ac 2 -bufsize 120k pipe:1", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + EnableRaisingEvents = true + }; + + process.ErrorDataReceived += (_, e) => + { + if (string.IsNullOrEmpty(e.Data)) return; + + var level = e.Data.Contains("error", StringComparison.OrdinalIgnoreCase) || + e.Data.Contains("failed", StringComparison.OrdinalIgnoreCase) + ? LogLevel.Error + : e.Data.Contains("warning", StringComparison.OrdinalIgnoreCase) + ? LogLevel.Warning + : LogLevel.Debug; + + logger.Log(level, "FFmpeg: {Message}", e.Data); + }; + + try + { + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start ffmpeg process."); + } + + process.BeginErrorReadLine(); + + logger.LogInformation("FFmpeg process started for URL: {AudioUrl} (PID: {ProcessId})", + audioUrl, process.Id); + } + catch + { + process.Dispose(); + throw; + } + + lock (_processLock) + { + _ffmpegProcess = process; + } + + return process; + } + + public async Task StopCurrentProcessAsync() + { + Process? process; + lock (_processLock) + { + process = _ffmpegProcess; + _ffmpegProcess = null; + } + + if (process is null) return; + + await TerminateProcessAsync(process); + } + + private async Task TerminateProcessAsync(Process process) + { + try + { + try + { + if (process.HasExited) + { + logger.LogDebug("FFmpeg process already exited (PID: {ProcessId})", process.Id); + return; + } + } + catch (InvalidOperationException) + { + // Process was never started or already disposed + return; + } + + logger.LogInformation("Terminating FFmpeg process (PID: {ProcessId})...", process.Id); + + // Try graceful shutdown first + try + { + if (!process.HasExited) + { + process.StandardInput.WriteLine("q"); + process.StandardInput.Close(); + } + } + catch (InvalidOperationException) + { + // Process might have exited between checks + } + + if (await WaitForExitAsync(process, TimeSpan.FromMilliseconds(1500))) + { + logger.LogInformation("FFmpeg process terminated gracefully (PID: {ProcessId})", process.Id); + return; + } + + // Force kill if still running + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await WaitForExitAsync(process, TimeSpan.FromMilliseconds(3000)); + logger.LogInformation("FFmpeg process killed (PID: {ProcessId})", process.Id); + } + } + catch (InvalidOperationException) + { + logger.LogDebug("Process exited before kill command"); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error terminating FFmpeg process"); + } + finally + { + try + { + process.Dispose(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error disposing FFmpeg process"); + } + } + } + + private static async Task<bool> WaitForExitAsync(Process process, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + try + { + await process.WaitForExitAsync(cts.Token); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } + + public void Dispose() + { + if (_disposed) return; + + Process? process; + lock (_processLock) + { + if (_disposed) return; + _disposed = true; + process = _ffmpegProcess; + _ffmpegProcess = null; + } + + if (process is null) return; + + // Best-effort synchronous kill during host shutdown. + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error killing FFmpeg process during dispose"); + } + finally + { + try + { + process.Dispose(); + } + catch + { + // ignored + } + } + } +} diff --git a/src/Infrastructure/Services/GuildPlayer.cs b/src/Infrastructure/Services/GuildPlayer.cs index 62dac82..ecf5442 100644 --- a/src/Infrastructure/Services/GuildPlayer.cs +++ b/src/Infrastructure/Services/GuildPlayer.cs @@ -1,163 +1,163 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Microsoft.Extensions.Logging; -using NetCord.Services.ComponentInteractions; - -namespace Infrastructure.Services; - -/// <summary> -/// Owns playback for a single guild: the single consumer of that guild's music queue -/// (queue service pattern from -/// https://learn.microsoft.com/en-us/dotnet/core/extensions/queue-service). -/// Dequeues one request at a time, plays it to completion via -/// <see cref="INetCordAudioPlayerService"/>, retries failed tracks, and disconnects -/// from the guild's voice channel when the queue runs empty. -/// Created and driven by <see cref="GuildPlayerManager"/>. -/// </summary> -public sealed class GuildPlayer( - IMusicQueueService queue, - INetCordAudioPlayerService audioPlayer, - ILogger logger) -{ - private const int MaxRetryCount = 3; - - private readonly Lock _ctsLock = new(); - private CancellationTokenSource? _trackCts; - - public IMusicQueueService Queue => queue; - - public async Task RunAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - PlayRequest<StringMenuInteractionContext> request; - try - { - request = await queue.DequeueAsync<StringMenuInteractionContext>(stoppingToken); - } - catch (OperationCanceledException) - { - break; - } - - queue.SetNowPlaying(request); - CancellationToken trackToken; - lock (_ctsLock) - { - _trackCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); - trackToken = _trackCts.Token; - } - - try - { - await PlayWithRetryAsync(request, trackToken); - } - finally - { - queue.SetNowPlaying(null); - lock (_ctsLock) - { - _trackCts.Dispose(); - _trackCts = null; - } - } - - if (queue.Count == 0 && !stoppingToken.IsCancellationRequested) - { - logger.LogInformation("Queue is empty - disconnecting from voice channel"); - try - { - await audioPlayer.DisconnectAsync(); - } - catch (Exception ex) - { - logger.LogError(ex, "Error disconnecting voice client"); - } - - await SafeCallbackAsync(request, - "Disconnected from voice channel either due to inactivity or error encountered."); - } - } - } - - private async Task PlayWithRetryAsync(PlayRequest request, CancellationToken trackToken) - { - for (var attempt = 1; attempt <= MaxRetryCount; attempt++) - { - TrackPlayResult result; - try - { - result = await audioPlayer.PlayTrackAsync(request, trackToken); - } - catch (OperationCanceledException) when (trackToken.IsCancellationRequested) - { - logger.LogInformation("Track {TrackId} cancelled (skip/stop)", request.Id); - return; - } - catch (Exception ex) - { - logger.LogError(ex, "Error playing track {TrackId} (attempt {Attempt}/{Max})", - request.Id, attempt, MaxRetryCount); - result = TrackPlayResult.Failed; - } - - switch (result) - { - case TrackPlayResult.Completed: - case TrackPlayResult.Skipped: - return; - - case TrackPlayResult.NotInVoiceChannel: - await SafeCallbackAsync(request, "You are not connected to any voice channel!"); - return; - - case TrackPlayResult.Failed when attempt < MaxRetryCount: - logger.LogWarning("Track {TrackId} failed, retrying: attempt {Attempt}/{Max}", - request.Id, attempt, MaxRetryCount); - continue; - - default: - logger.LogError("Track {TrackId} failed after {Max} attempts, dropping it", - request.Id, MaxRetryCount); - return; - } - } - } - - public void Skip() - { - CancellationTokenSource? cts; - lock (_ctsLock) - { - cts = _trackCts; - } - - try - { - cts?.Cancel(); - } - catch (ObjectDisposedException) - { - // The track finished between the read and the cancel; nothing to skip. - } - } - - public void Stop() - { - queue.Clear(); - Skip(); - } - - private async Task SafeCallbackAsync(PlayRequest request, string message) - { - try - { - await request.Callbacks.Invoke(message); - } - catch (Exception ex) - { - // An interaction can only be responded to once; a failed notification must not stop the player. - logger.LogWarning(ex, "Failed to send message to Discord: {Message}", message); - } - } -} +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.Logging; +using NetCord.Services.ComponentInteractions; + +namespace Infrastructure.Services; + +/// <summary> +/// Owns playback for a single guild: the single consumer of that guild's music queue +/// (queue service pattern from +/// https://learn.microsoft.com/en-us/dotnet/core/extensions/queue-service). +/// Dequeues one request at a time, plays it to completion via +/// <see cref="INetCordAudioPlayerService"/>, retries failed tracks, and disconnects +/// from the guild's voice channel when the queue runs empty. +/// Created and driven by <see cref="GuildPlayerManager"/>. +/// </summary> +public sealed class GuildPlayer( + IMusicQueueService queue, + INetCordAudioPlayerService audioPlayer, + ILogger logger) +{ + private const int MaxRetryCount = 3; + + private readonly Lock _ctsLock = new(); + private CancellationTokenSource? _trackCts; + + public IMusicQueueService Queue => queue; + + public async Task RunAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + PlayRequest<StringMenuInteractionContext> request; + try + { + request = await queue.DequeueAsync<StringMenuInteractionContext>(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + queue.SetNowPlaying(request); + CancellationToken trackToken; + lock (_ctsLock) + { + _trackCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + trackToken = _trackCts.Token; + } + + try + { + await PlayWithRetryAsync(request, trackToken); + } + finally + { + queue.SetNowPlaying(null); + lock (_ctsLock) + { + _trackCts.Dispose(); + _trackCts = null; + } + } + + if (queue.Count == 0 && !stoppingToken.IsCancellationRequested) + { + logger.LogInformation("Queue is empty - disconnecting from voice channel"); + try + { + await audioPlayer.DisconnectAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error disconnecting voice client"); + } + + await SafeCallbackAsync(request, + "Disconnected from voice channel either due to inactivity or error encountered."); + } + } + } + + private async Task PlayWithRetryAsync(PlayRequest request, CancellationToken trackToken) + { + for (var attempt = 1; attempt <= MaxRetryCount; attempt++) + { + TrackPlayResult result; + try + { + result = await audioPlayer.PlayTrackAsync(request, trackToken); + } + catch (OperationCanceledException) when (trackToken.IsCancellationRequested) + { + logger.LogInformation("Track {TrackId} cancelled (skip/stop)", request.Id); + return; + } + catch (Exception ex) + { + logger.LogError(ex, "Error playing track {TrackId} (attempt {Attempt}/{Max})", + request.Id, attempt, MaxRetryCount); + result = TrackPlayResult.Failed; + } + + switch (result) + { + case TrackPlayResult.Completed: + case TrackPlayResult.Skipped: + return; + + case TrackPlayResult.NotInVoiceChannel: + await SafeCallbackAsync(request, "You are not connected to any voice channel!"); + return; + + case TrackPlayResult.Failed when attempt < MaxRetryCount: + logger.LogWarning("Track {TrackId} failed, retrying: attempt {Attempt}/{Max}", + request.Id, attempt, MaxRetryCount); + continue; + + default: + logger.LogError("Track {TrackId} failed after {Max} attempts, dropping it", + request.Id, MaxRetryCount); + return; + } + } + } + + public void Skip() + { + CancellationTokenSource? cts; + lock (_ctsLock) + { + cts = _trackCts; + } + + try + { + cts?.Cancel(); + } + catch (ObjectDisposedException) + { + // The track finished between the read and the cancel; nothing to skip. + } + } + + public void Stop() + { + queue.Clear(); + Skip(); + } + + private async Task SafeCallbackAsync(PlayRequest request, string message) + { + try + { + await request.Callbacks.Invoke(message); + } + catch (Exception ex) + { + // An interaction can only be responded to once; a failed notification must not stop the player. + logger.LogWarning(ex, "Failed to send message to Discord: {Message}", message); + } + } +} diff --git a/src/Infrastructure/Services/GuildPlayerManager.cs b/src/Infrastructure/Services/GuildPlayerManager.cs index da266e0..7a8c193 100644 --- a/src/Infrastructure/Services/GuildPlayerManager.cs +++ b/src/Infrastructure/Services/GuildPlayerManager.cs @@ -1,184 +1,184 @@ -using System.Collections.Concurrent; -using Application.DTOs; -using Application.Interfaces.Services; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Services; - -/// <summary> -/// Components backing one guild's <see cref="GuildPlayer"/>. The optional disposable -/// (the guild's FFmpeg process service in production) is disposed when the manager -/// shuts down. -/// </summary> -public sealed record GuildPlayerComponents( - IMusicQueueService Queue, - INetCordAudioPlayerService AudioPlayer, - IDisposable? Disposable = null); - -/// <summary> -/// Owns one <see cref="GuildPlayer"/> per guild so multiple servers can play music -/// concurrently. Players (queue + consumer loop + audio player + FFmpeg service) are -/// created lazily on the first enqueue for a guild and kept for the process lifetime; -/// all loops are cancelled and per-guild components disposed on host shutdown. -/// </summary> -public sealed class GuildPlayerManager( - ILoggerFactory loggerFactory, - Func<ulong, GuildPlayerComponents> componentFactory) - : BackgroundService, IGuildMusicService -{ - private readonly ILogger<GuildPlayerManager> _logger = loggerFactory.CreateLogger<GuildPlayerManager>(); - private readonly ConcurrentDictionary<ulong, GuildPlayer> _players = new(); - private readonly List<Task> _loops = []; - private readonly List<IDisposable> _disposables = []; - private readonly Lock _createLock = new(); - private readonly CancellationTokenSource _stoppingCts = new(); - private bool _disposed; - - public void Enqueue<T>(ulong guildId, PlayRequest<T> request) => - GetOrCreatePlayer(guildId).Queue.Enqueue(request); - - public PlayRequest? GetNowPlaying(ulong guildId) => - _players.TryGetValue(guildId, out var player) ? player.Queue.NowPlaying : null; - - public PlayRequest[] GetAllRequests(ulong guildId) => - _players.TryGetValue(guildId, out var player) ? player.Queue.GetAllRequests() : []; - - public int GetQueueCount(ulong guildId) => - _players.TryGetValue(guildId, out var player) ? player.Queue.Count : 0; - - public void Rewind(ulong guildId) - { - if (_players.TryGetValue(guildId, out var player)) - { - player.Queue.Rewind(); - } - } - - public void Skip(ulong guildId) - { - if (_players.TryGetValue(guildId, out var player)) - { - player.Skip(); - } - } - - public void Stop(ulong guildId) - { - if (_players.TryGetValue(guildId, out var player)) - { - player.Stop(); - } - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Players run on their own tracked loop tasks; this just parks until shutdown. - try - { - await Task.Delay(Timeout.Infinite, stoppingToken); - } - catch (OperationCanceledException) - { - // Host is shutting down. - } - - await _stoppingCts.CancelAsync(); - - Task[] loops; - lock (_createLock) - { - loops = _loops.ToArray(); - } - - try - { - await Task.WhenAll(loops).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); - } - catch (TimeoutException) - { - _logger.LogWarning("Timed out waiting for guild player loops to stop"); - } - - DisposeComponents(); - } - - private GuildPlayer GetOrCreatePlayer(ulong guildId) - { - if (_players.TryGetValue(guildId, out var existing)) - { - return existing; - } - - // Plain lock instead of GetOrAdd: the factory starts a consumer loop, so it must - // run exactly once per guild. - lock (_createLock) - { - if (_players.TryGetValue(guildId, out existing)) - { - return existing; - } - - var components = componentFactory(guildId); - var player = new GuildPlayer(components.Queue, components.AudioPlayer, - loggerFactory.CreateLogger($"{typeof(GuildPlayer).FullName}[{guildId}]")); - - _loops.Add(RunPlayerLoopAsync(player, guildId)); - if (components.Disposable is not null) - { - _disposables.Add(components.Disposable); - } - - _players[guildId] = player; - _logger.LogInformation("Created music player for guild {GuildId}", guildId); - return player; - } - } - - private async Task RunPlayerLoopAsync(GuildPlayer player, ulong guildId) - { - try - { - await player.RunAsync(_stoppingCts.Token); - } - catch (Exception ex) - { - _logger.LogError(ex, "Music player loop for guild {GuildId} terminated unexpectedly", guildId); - } - } - - private void DisposeComponents() - { - IDisposable[] disposables; - lock (_createLock) - { - disposables = _disposables.ToArray(); - _disposables.Clear(); - } - - foreach (var disposable in disposables) - { - try - { - disposable.Dispose(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error disposing guild player component"); - } - } - } - - public override void Dispose() - { - if (!_disposed) - { - _disposed = true; - _stoppingCts.Cancel(); - _stoppingCts.Dispose(); - DisposeComponents(); - } - - base.Dispose(); - } -} +using System.Collections.Concurrent; +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +/// <summary> +/// Components backing one guild's <see cref="GuildPlayer"/>. The optional disposable +/// (the guild's FFmpeg process service in production) is disposed when the manager +/// shuts down. +/// </summary> +public sealed record GuildPlayerComponents( + IMusicQueueService Queue, + INetCordAudioPlayerService AudioPlayer, + IDisposable? Disposable = null); + +/// <summary> +/// Owns one <see cref="GuildPlayer"/> per guild so multiple servers can play music +/// concurrently. Players (queue + consumer loop + audio player + FFmpeg service) are +/// created lazily on the first enqueue for a guild and kept for the process lifetime; +/// all loops are cancelled and per-guild components disposed on host shutdown. +/// </summary> +public sealed class GuildPlayerManager( + ILoggerFactory loggerFactory, + Func<ulong, GuildPlayerComponents> componentFactory) + : BackgroundService, IGuildMusicService +{ + private readonly ILogger<GuildPlayerManager> _logger = loggerFactory.CreateLogger<GuildPlayerManager>(); + private readonly ConcurrentDictionary<ulong, GuildPlayer> _players = new(); + private readonly List<Task> _loops = []; + private readonly List<IDisposable> _disposables = []; + private readonly Lock _createLock = new(); + private readonly CancellationTokenSource _stoppingCts = new(); + private bool _disposed; + + public void Enqueue<T>(ulong guildId, PlayRequest<T> request) => + GetOrCreatePlayer(guildId).Queue.Enqueue(request); + + public PlayRequest? GetNowPlaying(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.NowPlaying : null; + + public PlayRequest[] GetAllRequests(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.GetAllRequests() : []; + + public int GetQueueCount(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.Count : 0; + + public void Rewind(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Queue.Rewind(); + } + } + + public void Skip(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Skip(); + } + } + + public void Stop(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Stop(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Players run on their own tracked loop tasks; this just parks until shutdown. + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + // Host is shutting down. + } + + await _stoppingCts.CancelAsync(); + + Task[] loops; + lock (_createLock) + { + loops = _loops.ToArray(); + } + + try + { + await Task.WhenAll(loops).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + catch (TimeoutException) + { + _logger.LogWarning("Timed out waiting for guild player loops to stop"); + } + + DisposeComponents(); + } + + private GuildPlayer GetOrCreatePlayer(ulong guildId) + { + if (_players.TryGetValue(guildId, out var existing)) + { + return existing; + } + + // Plain lock instead of GetOrAdd: the factory starts a consumer loop, so it must + // run exactly once per guild. + lock (_createLock) + { + if (_players.TryGetValue(guildId, out existing)) + { + return existing; + } + + var components = componentFactory(guildId); + var player = new GuildPlayer(components.Queue, components.AudioPlayer, + loggerFactory.CreateLogger($"{typeof(GuildPlayer).FullName}[{guildId}]")); + + _loops.Add(RunPlayerLoopAsync(player, guildId)); + if (components.Disposable is not null) + { + _disposables.Add(components.Disposable); + } + + _players[guildId] = player; + _logger.LogInformation("Created music player for guild {GuildId}", guildId); + return player; + } + } + + private async Task RunPlayerLoopAsync(GuildPlayer player, ulong guildId) + { + try + { + await player.RunAsync(_stoppingCts.Token); + } + catch (Exception ex) + { + _logger.LogError(ex, "Music player loop for guild {GuildId} terminated unexpectedly", guildId); + } + } + + private void DisposeComponents() + { + IDisposable[] disposables; + lock (_createLock) + { + disposables = _disposables.ToArray(); + _disposables.Clear(); + } + + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error disposing guild player component"); + } + } + } + + public override void Dispose() + { + if (!_disposed) + { + _disposed = true; + _stoppingCts.Cancel(); + _stoppingCts.Dispose(); + DisposeComponents(); + } + + base.Dispose(); + } +} diff --git a/src/Infrastructure/Services/MusicQueueService.cs b/src/Infrastructure/Services/MusicQueueService.cs index da5a888..e6b7678 100644 --- a/src/Infrastructure/Services/MusicQueueService.cs +++ b/src/Infrastructure/Services/MusicQueueService.cs @@ -1,125 +1,125 @@ -using System.Threading.Channels; -using Application.DTOs; -using Application.Interfaces.Services; -using NetCord.Services.ComponentInteractions; - -namespace Infrastructure.Services; - -/// <summary> -/// In-memory music queue following the channel-backed queue service pattern: -/// a lock-protected list is the source of truth and an unbounded channel is the -/// async signal consumed by the single player background service. -/// Every enqueue writes exactly one signal; a dequeue consumes one signal per -/// returned item, so stale signals left behind by <see cref="Clear"/> are -/// absorbed harmlessly (the consumer finds the list empty and waits again). -/// </summary> -public class MusicQueueService : IMusicQueueService -{ - private readonly Channel<bool> _signal = Channel.CreateUnbounded<bool>(); - private readonly List<PlayRequest<StringMenuInteractionContext>> _items = []; - private readonly Lock _lock = new(); - private PlayRequest? _nowPlaying; - - public void Enqueue<T>(PlayRequest<T> request) - { - if (request is not PlayRequest<StringMenuInteractionContext> playRequest) - { - throw new ArgumentException( - $"Invalid request type. Expected {typeof(PlayRequest<StringMenuInteractionContext>)}", - nameof(request)); - } - - lock (_lock) - { - _items.Add(playRequest); - } - - _signal.Writer.TryWrite(true); - } - - public async ValueTask<PlayRequest<T>> DequeueAsync<T>(CancellationToken cancellationToken) - { - while (true) - { - await _signal.Reader.ReadAsync(cancellationToken); - - lock (_lock) - { - if (_items.Count > 0) - { - var item = _items[0]; - _items.RemoveAt(0); - return item as PlayRequest<T> - ?? throw new InvalidOperationException( - $"Queued request is not of the expected type {typeof(PlayRequest<T>)}."); - } - } - - // Stale signal (the queue was cleared since the signal was written); wait for the next enqueue. - } - } - - public int Count - { - get - { - lock (_lock) - { - return _items.Count; - } - } - } - - public PlayRequest? NowPlaying - { - get - { - lock (_lock) - { - return _nowPlaying; - } - } - } - - public void SetNowPlaying(PlayRequest? request) - { - lock (_lock) - { - _nowPlaying = request; - } - } - - public PlayRequest[] GetAllRequests() - { - lock (_lock) - { - return _nowPlaying is null - ? _items.ToArray<PlayRequest>() - : [_nowPlaying, .. _items]; - } - } - - public void Rewind() - { - lock (_lock) - { - if (_nowPlaying is not PlayRequest<StringMenuInteractionContext> current) - { - return; - } - - current.RetryCount = 0; - _items.Insert(0, current); - } - - _signal.Writer.TryWrite(true); - } - - public void Clear() - { - lock (_lock) - { - _items.Clear(); - } - } -} +using System.Threading.Channels; +using Application.DTOs; +using Application.Interfaces.Services; +using NetCord.Services.ComponentInteractions; + +namespace Infrastructure.Services; + +/// <summary> +/// In-memory music queue following the channel-backed queue service pattern: +/// a lock-protected list is the source of truth and an unbounded channel is the +/// async signal consumed by the single player background service. +/// Every enqueue writes exactly one signal; a dequeue consumes one signal per +/// returned item, so stale signals left behind by <see cref="Clear"/> are +/// absorbed harmlessly (the consumer finds the list empty and waits again). +/// </summary> +public class MusicQueueService : IMusicQueueService +{ + private readonly Channel<bool> _signal = Channel.CreateUnbounded<bool>(); + private readonly List<PlayRequest<StringMenuInteractionContext>> _items = []; + private readonly Lock _lock = new(); + private PlayRequest? _nowPlaying; + + public void Enqueue<T>(PlayRequest<T> request) + { + if (request is not PlayRequest<StringMenuInteractionContext> playRequest) + { + throw new ArgumentException( + $"Invalid request type. Expected {typeof(PlayRequest<StringMenuInteractionContext>)}", + nameof(request)); + } + + lock (_lock) + { + _items.Add(playRequest); + } + + _signal.Writer.TryWrite(true); + } + + public async ValueTask<PlayRequest<T>> DequeueAsync<T>(CancellationToken cancellationToken) + { + while (true) + { + await _signal.Reader.ReadAsync(cancellationToken); + + lock (_lock) + { + if (_items.Count > 0) + { + var item = _items[0]; + _items.RemoveAt(0); + return item as PlayRequest<T> + ?? throw new InvalidOperationException( + $"Queued request is not of the expected type {typeof(PlayRequest<T>)}."); + } + } + + // Stale signal (the queue was cleared since the signal was written); wait for the next enqueue. + } + } + + public int Count + { + get + { + lock (_lock) + { + return _items.Count; + } + } + } + + public PlayRequest? NowPlaying + { + get + { + lock (_lock) + { + return _nowPlaying; + } + } + } + + public void SetNowPlaying(PlayRequest? request) + { + lock (_lock) + { + _nowPlaying = request; + } + } + + public PlayRequest[] GetAllRequests() + { + lock (_lock) + { + return _nowPlaying is null + ? _items.ToArray<PlayRequest>() + : [_nowPlaying, .. _items]; + } + } + + public void Rewind() + { + lock (_lock) + { + if (_nowPlaying is not PlayRequest<StringMenuInteractionContext> current) + { + return; + } + + current.RetryCount = 0; + _items.Insert(0, current); + } + + _signal.Writer.TryWrite(true); + } + + public void Clear() + { + lock (_lock) + { + _items.Clear(); + } + } +} diff --git a/src/Infrastructure/Services/PlayerHandler.cs b/src/Infrastructure/Services/PlayerHandler.cs index 6a55b15..8b950ce 100644 --- a/src/Infrastructure/Services/PlayerHandler.cs +++ b/src/Infrastructure/Services/PlayerHandler.cs @@ -1,28 +1,28 @@ -using Application.Interfaces.Services; -using Domain.Common; -using Domain.Eventing; -using Microsoft.Extensions.Logging; - -namespace Infrastructure.Services; - -/// <summary> -/// Thin adapter translating guild-scoped Skip/Stop events into player signals. -/// Playback itself is driven by the per-guild <see cref="GuildPlayer"/> loops owned -/// by <see cref="GuildPlayerManager"/>. -/// </summary> -public class PlayerHandler(IGuildMusicService guildMusicService, ILogger<PlayerHandler> logger) - : IEventHandler<EventType.Skip>, IEventHandler<EventType.Stop> -{ - public void Handle(EventType.Skip @event) - { - logger.LogInformation("Skip event received for guild {GuildId} - cancelling current track", @event.GuildId); - guildMusicService.Skip(@event.GuildId); - } - - public void Handle(EventType.Stop @event) - { - logger.LogInformation("Stop event received for guild {GuildId} - clearing queue and stopping playback", - @event.GuildId); - guildMusicService.Stop(@event.GuildId); - } -} +using Application.Interfaces.Services; +using Domain.Common; +using Domain.Eventing; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +/// <summary> +/// Thin adapter translating guild-scoped Skip/Stop events into player signals. +/// Playback itself is driven by the per-guild <see cref="GuildPlayer"/> loops owned +/// by <see cref="GuildPlayerManager"/>. +/// </summary> +public class PlayerHandler(IGuildMusicService guildMusicService, ILogger<PlayerHandler> logger) + : IEventHandler<EventType.Skip>, IEventHandler<EventType.Stop> +{ + public void Handle(EventType.Skip @event) + { + logger.LogInformation("Skip event received for guild {GuildId} - cancelling current track", @event.GuildId); + guildMusicService.Skip(@event.GuildId); + } + + public void Handle(EventType.Stop @event) + { + logger.LogInformation("Stop event received for guild {GuildId} - clearing queue and stopping playback", + @event.GuildId); + guildMusicService.Stop(@event.GuildId); + } +} diff --git a/src/Infrastructure/Services/RadioSourceService.cs b/src/Infrastructure/Services/RadioSourceService.cs index daf9a35..17c8466 100644 --- a/src/Infrastructure/Services/RadioSourceService.cs +++ b/src/Infrastructure/Services/RadioSourceService.cs @@ -1,67 +1,67 @@ -using Application.Interfaces.Services; -using Domain.Entities; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; - -namespace Infrastructure.Services; - -public class RadioSourceService(DiscordBotContext context): IRadioSourceService -{ - public async Task<IReadOnlyCollection<RadioSource>> GetAllRadioSourcesAsync(CancellationToken cancellationToken = default) - { - return await context.RadioSources.OrderBy(r => r.Name).ToListAsync(cancellationToken); - } - - public async Task<RadioSource> GetRadioSourceByIdAsync(Guid id, CancellationToken cancellationToken = default) - { - return await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken) ?? - throw new KeyNotFoundException($"Radio source with ID {id} not found."); - } - - public async Task UpdateRadioSourceUrlAsync(Guid id, string name, string newSourceUrl, bool isActive, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(newSourceUrl)) - { - throw new ArgumentException("Source URL cannot be null or empty.", nameof(newSourceUrl)); - } - - var radioSource = await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken); - if (radioSource == null) - { - throw new KeyNotFoundException($"Radio source with ID {id} not found."); - } - - RadioSource.Update(radioSource, name, newSourceUrl, isActive); - - context.RadioSources.Update(radioSource); - - await context.SaveChangesAsync(cancellationToken); - } - - public async Task<Guid> AddRadioSourceAsync(string name, string sourceUrl, CancellationToken cancellationToken = default) - { - var limitCount = await context.RadioSources.CountAsync(cancellationToken); - if (limitCount >= 12) - { - throw new InvalidOperationException("Cannot add more than 12 radio sources."); - } - - var radioSource = RadioSource.Create(name, sourceUrl); - context.RadioSources.Add(radioSource); - await context.SaveChangesAsync(cancellationToken); - - return radioSource.Id; - } - - public async Task<int> DeleteRadioSourceAsync(Guid id, CancellationToken cancellationToken = default) - { - var radioSource = await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken: cancellationToken); - if (radioSource == null) - { - throw new KeyNotFoundException($"Radio source with ID {id} not found."); - } - - context.RadioSources.Remove(radioSource); - return await context.SaveChangesAsync(cancellationToken); - } +using Application.Interfaces.Services; +using Domain.Entities; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Services; + +public class RadioSourceService(DiscordBotContext context): IRadioSourceService +{ + public async Task<IReadOnlyCollection<RadioSource>> GetAllRadioSourcesAsync(CancellationToken cancellationToken = default) + { + return await context.RadioSources.OrderBy(r => r.Name).ToListAsync(cancellationToken); + } + + public async Task<RadioSource> GetRadioSourceByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken) ?? + throw new KeyNotFoundException($"Radio source with ID {id} not found."); + } + + public async Task UpdateRadioSourceUrlAsync(Guid id, string name, string newSourceUrl, bool isActive, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(newSourceUrl)) + { + throw new ArgumentException("Source URL cannot be null or empty.", nameof(newSourceUrl)); + } + + var radioSource = await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken); + if (radioSource == null) + { + throw new KeyNotFoundException($"Radio source with ID {id} not found."); + } + + RadioSource.Update(radioSource, name, newSourceUrl, isActive); + + context.RadioSources.Update(radioSource); + + await context.SaveChangesAsync(cancellationToken); + } + + public async Task<Guid> AddRadioSourceAsync(string name, string sourceUrl, CancellationToken cancellationToken = default) + { + var limitCount = await context.RadioSources.CountAsync(cancellationToken); + if (limitCount >= 12) + { + throw new InvalidOperationException("Cannot add more than 12 radio sources."); + } + + var radioSource = RadioSource.Create(name, sourceUrl); + context.RadioSources.Add(radioSource); + await context.SaveChangesAsync(cancellationToken); + + return radioSource.Id; + } + + public async Task<int> DeleteRadioSourceAsync(Guid id, CancellationToken cancellationToken = default) + { + var radioSource = await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken: cancellationToken); + if (radioSource == null) + { + throw new KeyNotFoundException($"Radio source with ID {id} not found."); + } + + context.RadioSources.Remove(radioSource); + return await context.SaveChangesAsync(cancellationToken); + } } \ No newline at end of file diff --git a/src/Infrastructure/Services/ScopeExecutor.cs b/src/Infrastructure/Services/ScopeExecutor.cs index b626f74..4077df5 100644 --- a/src/Infrastructure/Services/ScopeExecutor.cs +++ b/src/Infrastructure/Services/ScopeExecutor.cs @@ -1,18 +1,18 @@ -using Application.Interfaces.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Infrastructure.Services; - -public class ScopeExecutor(IServiceScopeFactory scopeFactory) : IScopeExecutor -{ - public async Task ExecuteAsync(Func<IServiceProvider, Task> action) - { - using var scope = scopeFactory.CreateScope(); - await action(scope.ServiceProvider); - } - public void Execute(Action<IServiceProvider> action) - { - using var scope = scopeFactory.CreateScope(); - action(scope.ServiceProvider); - } +using Application.Interfaces.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.Services; + +public class ScopeExecutor(IServiceScopeFactory scopeFactory) : IScopeExecutor +{ + public async Task ExecuteAsync(Func<IServiceProvider, Task> action) + { + using var scope = scopeFactory.CreateScope(); + await action(scope.ServiceProvider); + } + public void Execute(Action<IServiceProvider> action) + { + using var scope = scopeFactory.CreateScope(); + action(scope.ServiceProvider); + } } \ No newline at end of file diff --git a/src/Infrastructure/Services/SoundCloudService.cs b/src/Infrastructure/Services/SoundCloudService.cs index 82385eb..2a28b8c 100644 --- a/src/Infrastructure/Services/SoundCloudService.cs +++ b/src/Infrastructure/Services/SoundCloudService.cs @@ -1,32 +1,32 @@ -using Application.Interfaces.Services; -using Microsoft.Extensions.Logging; -using SoundCloudExplode; - -namespace Infrastructure.Services; - -public class SoundCloudService(ILogger<SoundCloudService> logger, SoundCloudClient soundCloudClient): IStreamService -{ - public async Task<string> GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken) - { - try - { - await soundCloudClient.InitializeAsync(cancellationToken); - var audioStream = await soundCloudClient.Tracks.GetDownloadUrlAsync(url, cancellationToken) - ?? throw new InvalidOperationException("No suitable audio format found."); - - return audioStream; - } - catch (Exception ex) - { - logger.LogWarning(ex, "SoundCloudClient failed for: {Url}", url); - throw; - } - } - - public async Task<string> GetVideoTitleAsync(string url, CancellationToken cancellationToken) - { - await soundCloudClient.InitializeAsync(cancellationToken); - var track = await soundCloudClient.Tracks.GetAsync(url, cancellationToken); - return track?.Title ?? throw new InvalidOperationException($"Could not resolve track title for: {url}"); - } -} +using Application.Interfaces.Services; +using Microsoft.Extensions.Logging; +using SoundCloudExplode; + +namespace Infrastructure.Services; + +public class SoundCloudService(ILogger<SoundCloudService> logger, SoundCloudClient soundCloudClient): IStreamService +{ + public async Task<string> GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken) + { + try + { + await soundCloudClient.InitializeAsync(cancellationToken); + var audioStream = await soundCloudClient.Tracks.GetDownloadUrlAsync(url, cancellationToken) + ?? throw new InvalidOperationException("No suitable audio format found."); + + return audioStream; + } + catch (Exception ex) + { + logger.LogWarning(ex, "SoundCloudClient failed for: {Url}", url); + throw; + } + } + + public async Task<string> GetVideoTitleAsync(string url, CancellationToken cancellationToken) + { + await soundCloudClient.InitializeAsync(cancellationToken); + var track = await soundCloudClient.Tracks.GetAsync(url, cancellationToken); + return track?.Title ?? throw new InvalidOperationException($"Could not resolve track title for: {url}"); + } +} diff --git a/src/Infrastructure/Services/StatisticsService.cs b/src/Infrastructure/Services/StatisticsService.cs index 3d5cf4b..ac84fc1 100644 --- a/src/Infrastructure/Services/StatisticsService.cs +++ b/src/Infrastructure/Services/StatisticsService.cs @@ -1,179 +1,179 @@ -using Application.DTOs; -using Application.DTOs.Stats; -using Application.Interfaces.Services; -using Domain.Entities; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Song = Domain.Entities.Song; - -namespace Infrastructure.Services; - -public class StatisticsService( - DiscordBotContext context, - [FromKeyedServices(nameof(YoutubeService))] IStreamService streamService, - ILogger<StatisticsService> logger) - : IStatisticsService -{ - // Log when a user plays a song - - - public async Task LogSongPlayAsync(ulong id, string userName, string globalName, SongDtoBase songDto) - { - try - { - // Ensure user exists - var user = await context.Users.FirstOrDefaultAsync(u => u.Id == id); - if (user == null) - { - user = User.Create(id, userName, globalName); - context.Users.Add(user); - await context.SaveChangesAsync(); - } - - // Find or create song - - var song = await context.Songs - .FirstOrDefaultAsync(s => s.SourceUrl == songDto.Url) ?? - (string.IsNullOrEmpty(songDto.Title) - ? null - : await context.Songs - .FirstOrDefaultAsync(s => s.Title.ToLower() == songDto.Title.ToLower())); - - if (song == null) - { - var songTitle = string.IsNullOrEmpty(songDto.Title) - ? await streamService.GetVideoTitleAsync(songDto.Url, CancellationToken.None) - : songDto.Title; - song = Song.Create(songDto.Url, songTitle); - context.Songs.Add(song); - } - - // Save to get song ID if it's new - await context.SaveChangesAsync(); - - var existingPlayHistory = await context.PlayHistory - .FirstOrDefaultAsync(ph => ph.UserId == user.Id && ph.SongId == song.Id); - - if (existingPlayHistory != null) - { - PlayHistory.UpdateTotalPlays(existingPlayHistory); - context.PlayHistory.Update(existingPlayHistory); - } - else - { - // Create new play history entry - var playHistory = PlayHistory.Create(DateTimeOffset.UtcNow, user.Id, song.Id); - context.PlayHistory.Add(playHistory); - } - - // Update user's total play count - user = User.UpdateTotalSongsPlayed(user); - context.Users.Update(user); - - await context.SaveChangesAsync(); - } - catch (Exception ex) - { - // Log error but don't break the music bot - logger.LogError(ex, "Error logging song play for user {UserId} and URL {Url}", id, songDto.Url); - } - } - - public async Task<List<TopSongDto>> GetUserTopSongsAsync(ulong userId, int limit = 10) - { - return await context.PlayHistory - .Where(ph => ph.UserId == userId) - .Select(ph => new TopSongDto - { - Title = ph.Song.Title, - PlayCount = ph.TotalPlays, - LastPlayed = ph.PlayedAt - }) - .OrderByDescending(uts => uts.PlayCount) - .Take(limit) - .ToListAsync(); - } - - // Get user's total stats - public async Task<UserStatsDto?> GetUserStatsAsync(ulong userId) - { - var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId); - if (user == null) return null; - - // count based on total plays props - var totalSongs = await context.PlayHistory - .Where(ph => ph.UserId == userId) - .SumAsync(ph => ph.TotalPlays); - - var uniqueSongs = await context.PlayHistory - .Where(ph => ph.UserId == userId) - .Select(ph => ph.SongId) - .Distinct() - .CountAsync(); - - return new UserStatsDto - { - Username = user.Username, - TotalPlays = totalSongs, - UniqueSongs = uniqueSongs, - MemberSince = user.CreatedAt - }; - } - - // Get user's recent activity - public async Task<List<RecentPlayDto>> GetUserRecentPlaysAsync(ulong userId, int limit = 10) - { - return await context.PlayHistory - .Where(ph => ph.UserId == userId) - .OrderByDescending(ph => ph.PlayedAt) - .Select(ph => new RecentPlayDto - { - Title = ph.Song.Title, - PlayedAt = ph.PlayedAt - }) - .Take(limit) - .ToListAsync(); - } - - public async Task<List<TopSongDto>> GetTopSongsAsync(bool isToday = false, int limit = 10) - { - var query = context.PlayHistory.AsQueryable(); - - if (isToday) - { - var localToday = DateTime.Today; - var offset = TimeZoneInfo.Local.GetUtcOffset(localToday); - var localDateTime = new DateTimeOffset(localToday, offset); - var utcTodayStart = localDateTime.ToUniversalTime(); - query = query.Where(ph => ph.PlayedAt >= utcTodayStart); - } - - return await query - .GroupBy(ph => new { ph.SongId, ph.Song.Title }) - .Select(g => new TopSongDto - { - Title = g.Key.Title, - PlayCount = g.Sum(ph => ph.TotalPlays), - LastPlayed = g.Max(ph => ph.PlayedAt) - }) - .OrderByDescending(ts => ts.PlayCount) - .Take(limit) - .ToListAsync(); - } - - public async Task<List<TopSongDto>> GetAllSongsAsync() - { - return await context.PlayHistory - .GroupBy(ph => new { ph.SongId, ph.Song.Title }) - .Select(g => new TopSongDto - { - Title = g.Key.Title, - PlayCount = g.Sum(ph => ph.TotalPlays), - LastPlayed = g.Max(ph => ph.PlayedAt) - }) - .OrderByDescending(ts => ts.PlayCount) - .ToListAsync(); - } +using Application.DTOs; +using Application.DTOs.Stats; +using Application.Interfaces.Services; +using Domain.Entities; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Song = Domain.Entities.Song; + +namespace Infrastructure.Services; + +public class StatisticsService( + DiscordBotContext context, + [FromKeyedServices(nameof(YoutubeService))] IStreamService streamService, + ILogger<StatisticsService> logger) + : IStatisticsService +{ + // Log when a user plays a song + + + public async Task LogSongPlayAsync(ulong id, string userName, string globalName, SongDtoBase songDto) + { + try + { + // Ensure user exists + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == id); + if (user == null) + { + user = User.Create(id, userName, globalName); + context.Users.Add(user); + await context.SaveChangesAsync(); + } + + // Find or create song + + var song = await context.Songs + .FirstOrDefaultAsync(s => s.SourceUrl == songDto.Url) ?? + (string.IsNullOrEmpty(songDto.Title) + ? null + : await context.Songs + .FirstOrDefaultAsync(s => s.Title.ToLower() == songDto.Title.ToLower())); + + if (song == null) + { + var songTitle = string.IsNullOrEmpty(songDto.Title) + ? await streamService.GetVideoTitleAsync(songDto.Url, CancellationToken.None) + : songDto.Title; + song = Song.Create(songDto.Url, songTitle); + context.Songs.Add(song); + } + + // Save to get song ID if it's new + await context.SaveChangesAsync(); + + var existingPlayHistory = await context.PlayHistory + .FirstOrDefaultAsync(ph => ph.UserId == user.Id && ph.SongId == song.Id); + + if (existingPlayHistory != null) + { + PlayHistory.UpdateTotalPlays(existingPlayHistory); + context.PlayHistory.Update(existingPlayHistory); + } + else + { + // Create new play history entry + var playHistory = PlayHistory.Create(DateTimeOffset.UtcNow, user.Id, song.Id); + context.PlayHistory.Add(playHistory); + } + + // Update user's total play count + user = User.UpdateTotalSongsPlayed(user); + context.Users.Update(user); + + await context.SaveChangesAsync(); + } + catch (Exception ex) + { + // Log error but don't break the music bot + logger.LogError(ex, "Error logging song play for user {UserId} and URL {Url}", id, songDto.Url); + } + } + + public async Task<List<TopSongDto>> GetUserTopSongsAsync(ulong userId, int limit = 10) + { + return await context.PlayHistory + .Where(ph => ph.UserId == userId) + .Select(ph => new TopSongDto + { + Title = ph.Song.Title, + PlayCount = ph.TotalPlays, + LastPlayed = ph.PlayedAt + }) + .OrderByDescending(uts => uts.PlayCount) + .Take(limit) + .ToListAsync(); + } + + // Get user's total stats + public async Task<UserStatsDto?> GetUserStatsAsync(ulong userId) + { + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) return null; + + // count based on total plays props + var totalSongs = await context.PlayHistory + .Where(ph => ph.UserId == userId) + .SumAsync(ph => ph.TotalPlays); + + var uniqueSongs = await context.PlayHistory + .Where(ph => ph.UserId == userId) + .Select(ph => ph.SongId) + .Distinct() + .CountAsync(); + + return new UserStatsDto + { + Username = user.Username, + TotalPlays = totalSongs, + UniqueSongs = uniqueSongs, + MemberSince = user.CreatedAt + }; + } + + // Get user's recent activity + public async Task<List<RecentPlayDto>> GetUserRecentPlaysAsync(ulong userId, int limit = 10) + { + return await context.PlayHistory + .Where(ph => ph.UserId == userId) + .OrderByDescending(ph => ph.PlayedAt) + .Select(ph => new RecentPlayDto + { + Title = ph.Song.Title, + PlayedAt = ph.PlayedAt + }) + .Take(limit) + .ToListAsync(); + } + + public async Task<List<TopSongDto>> GetTopSongsAsync(bool isToday = false, int limit = 10) + { + var query = context.PlayHistory.AsQueryable(); + + if (isToday) + { + var localToday = DateTime.Today; + var offset = TimeZoneInfo.Local.GetUtcOffset(localToday); + var localDateTime = new DateTimeOffset(localToday, offset); + var utcTodayStart = localDateTime.ToUniversalTime(); + query = query.Where(ph => ph.PlayedAt >= utcTodayStart); + } + + return await query + .GroupBy(ph => new { ph.SongId, ph.Song.Title }) + .Select(g => new TopSongDto + { + Title = g.Key.Title, + PlayCount = g.Sum(ph => ph.TotalPlays), + LastPlayed = g.Max(ph => ph.PlayedAt) + }) + .OrderByDescending(ts => ts.PlayCount) + .Take(limit) + .ToListAsync(); + } + + public async Task<List<TopSongDto>> GetAllSongsAsync() + { + return await context.PlayHistory + .GroupBy(ph => new { ph.SongId, ph.Song.Title }) + .Select(g => new TopSongDto + { + Title = g.Key.Title, + PlayCount = g.Sum(ph => ph.TotalPlays), + LastPlayed = g.Max(ph => ph.PlayedAt) + }) + .OrderByDescending(ts => ts.PlayCount) + .ToListAsync(); + } } \ No newline at end of file diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index f1bb310..1361094 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -1,57 +1,57 @@ -using Application.DTOs.Stats; -using Application.Interfaces.Services; -using Domain.Entities; -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; - -namespace Infrastructure.Services; - -public class UserService(DiscordBotContext context) : IUserService -{ - public async Task<User?> GetUserByUsernameAsync(string username) - { - var user = await context.Users - .FirstOrDefaultAsync(u => u.Username == username); - - return user; - } - - public async Task<User?> GetUserByDisplayNameAsync(string displayName) - { - var user = await context.Users - .FirstOrDefaultAsync(u => u.DisplayName == displayName); - - return user; - } - - public async Task<ICollection<UserStatsDto>> GetAllUsersAsync() - { - var users = await context.Users - .Select(u => new UserStatsDto - { - Username = u.Username, - MemberSince = u.CreatedAt, - TotalPlays = u.PlayHistories.Sum(ph => ph.TotalPlays), - UniqueSongs = u.PlayHistories.Select(ph => ph.Song).Distinct().Count(), - LastPlayed = u.PlayHistories - .OrderByDescending(ph => ph.PlayedAt) - .Select(ph => (DateTimeOffset?)ph.PlayedAt) - .FirstOrDefault(), - DisplayName = u.DisplayName ?? u.Username, - RecentSongs = u.PlayHistories - .OrderByDescending(ph => ph.PlayedAt) - .Take(20) - .Select(ph => new RecentSongDto - { - Title = ph.Song.Title, - TotalPlays = ph.TotalPlays, - PlayedAt = ph.PlayedAt - }) - .ToList() - }) - .OrderByDescending(u => u.TotalPlays) - .ToListAsync(); - - return users; - } +using Application.DTOs.Stats; +using Application.Interfaces.Services; +using Domain.Entities; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Services; + +public class UserService(DiscordBotContext context) : IUserService +{ + public async Task<User?> GetUserByUsernameAsync(string username) + { + var user = await context.Users + .FirstOrDefaultAsync(u => u.Username == username); + + return user; + } + + public async Task<User?> GetUserByDisplayNameAsync(string displayName) + { + var user = await context.Users + .FirstOrDefaultAsync(u => u.DisplayName == displayName); + + return user; + } + + public async Task<ICollection<UserStatsDto>> GetAllUsersAsync() + { + var users = await context.Users + .Select(u => new UserStatsDto + { + Username = u.Username, + MemberSince = u.CreatedAt, + TotalPlays = u.PlayHistories.Sum(ph => ph.TotalPlays), + UniqueSongs = u.PlayHistories.Select(ph => ph.Song).Distinct().Count(), + LastPlayed = u.PlayHistories + .OrderByDescending(ph => ph.PlayedAt) + .Select(ph => (DateTimeOffset?)ph.PlayedAt) + .FirstOrDefault(), + DisplayName = u.DisplayName ?? u.Username, + RecentSongs = u.PlayHistories + .OrderByDescending(ph => ph.PlayedAt) + .Take(20) + .Select(ph => new RecentSongDto + { + Title = ph.Song.Title, + TotalPlays = ph.TotalPlays, + PlayedAt = ph.PlayedAt + }) + .ToList() + }) + .OrderByDescending(u => u.TotalPlays) + .ToListAsync(); + + return users; + } } \ No newline at end of file diff --git a/src/Infrastructure/Services/YoutubeService.cs b/src/Infrastructure/Services/YoutubeService.cs index 3acdf5d..3c0a278 100644 --- a/src/Infrastructure/Services/YoutubeService.cs +++ b/src/Infrastructure/Services/YoutubeService.cs @@ -1,151 +1,151 @@ -using Application.Interfaces.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using YoutubeDLSharp; -using YoutubeExplode; -using YoutubeExplode.Videos.Streams; - -namespace Infrastructure.Services; - -public class YoutubeService: IStreamService -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger<YoutubeService> _logger; - private readonly List<Func<string, CancellationToken, Task<(bool Success, string? Url)>>> _providerStrategy; - private readonly YoutubeClient _youtubeClient; - - public YoutubeService( - IServiceProvider serviceProvider, - ILogger<YoutubeService> logger, - YoutubeClient youtubeClient) - { - _serviceProvider = serviceProvider; - _logger = logger; - _youtubeClient = youtubeClient; - - _providerStrategy = - [ - TryGetWithYtDlpAsync, - TryGetWithYoutubeExplodeAsync, - ]; - } - - public async Task<string> GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken) - { - foreach (var strategy in _providerStrategy) - { - var result = await ExecuteWithTimeout(strategy, url, TimeSpan.FromSeconds(15), cancellationToken); - if (result.Success) - { - _logger.LogInformation("Successfully obtained stream URL using {Provider}", - strategy.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); - return result.Url!; - } - } - - _logger.LogError("All audio stream providers failed for: {Url}", url); - throw new InvalidOperationException("No suitable audio format found from any provider."); - } - - private async Task<(bool Success, string? Url)> ExecuteWithTimeout( - Func<string, CancellationToken, Task<(bool, string?)>> func, - string url, - TimeSpan timeout, - CancellationToken cancellationToken) - { - try - { - return await func(url, cancellationToken).WaitAsync(timeout, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (TimeoutException) - { - _logger.LogWarning("Provider {Provider} timed out after {Timeout} seconds", - func.Method.Name.Replace("TryGetWith", "").Replace("Async", ""), - timeout.TotalSeconds); - return (false, null); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Provider {Provider} failed", - func.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); - return (false, null); - } - } - - private async Task<(bool Success, string? Url)> TryGetWithYtDlpAsync(string url, CancellationToken cancellationToken) - { - try - { - var ytdl = new YoutubeDL { YoutubeDLPath = "yt-dlp" }; - var overrideOptions = new YoutubeDLSharp.Options.OptionSet - { - Format = "bestaudio/best" - }; - var result = await ytdl.RunVideoDataFetch(url, ct: cancellationToken, overrideOptions: overrideOptions); - - if (!result.Success || result.Data?.Formats == null) - { - _logger.LogWarning("YT-DLP fetch failed for: {Url}. Errors: {Errors}", url, - string.Join("; ", result.ErrorOutput ?? [])); - return (false, null); - } - - var httpsFormats = result.Data.Formats - .Where(f => f.Protocol != "mhtml") - .AsQueryable(); - - var bestAudio = httpsFormats - .MaxBy(f => f.AudioBitrate ?? 0) - ?? httpsFormats - .MaxBy(f => f.Bitrate ?? 0); - - if (bestAudio?.Url == null) - { - _logger.LogWarning("YT-DLP found no suitable audio format for: {Url}", url); - return (false, null); - } - - return (true, bestAudio.Url); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "YT-DLP failed for: {Url}", url); - return (false, null); - } - } - - private async Task<(bool Success, string? Url)> TryGetWithYoutubeExplodeAsync(string url, CancellationToken cancellationToken) - { - try - { - using var scope = _serviceProvider.CreateScope(); - var youtubeClient = scope.ServiceProvider.GetRequiredService<YoutubeClient>(); - var manifest = await youtubeClient.Videos.Streams.GetManifestAsync(url, cancellationToken); - var audioStream = manifest.GetAudioOnlyStreams().GetWithHighestBitrate(); - - return (true, audioStream.Url); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "YoutubeExplode failed for: {Url}", url); - return (false, null); - } - } - - public async Task<string> GetVideoTitleAsync(string url, CancellationToken cancellationToken) - { - return (await _youtubeClient.Videos.GetAsync(url, cancellationToken)).Title; - } -} +using Application.Interfaces.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using YoutubeDLSharp; +using YoutubeExplode; +using YoutubeExplode.Videos.Streams; + +namespace Infrastructure.Services; + +public class YoutubeService: IStreamService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger<YoutubeService> _logger; + private readonly List<Func<string, CancellationToken, Task<(bool Success, string? Url)>>> _providerStrategy; + private readonly YoutubeClient _youtubeClient; + + public YoutubeService( + IServiceProvider serviceProvider, + ILogger<YoutubeService> logger, + YoutubeClient youtubeClient) + { + _serviceProvider = serviceProvider; + _logger = logger; + _youtubeClient = youtubeClient; + + _providerStrategy = + [ + TryGetWithYtDlpAsync, + TryGetWithYoutubeExplodeAsync, + ]; + } + + public async Task<string> GetAudioStreamUrlAsync(string url, CancellationToken cancellationToken) + { + foreach (var strategy in _providerStrategy) + { + var result = await ExecuteWithTimeout(strategy, url, TimeSpan.FromSeconds(15), cancellationToken); + if (result.Success) + { + _logger.LogInformation("Successfully obtained stream URL using {Provider}", + strategy.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); + return result.Url!; + } + } + + _logger.LogError("All audio stream providers failed for: {Url}", url); + throw new InvalidOperationException("No suitable audio format found from any provider."); + } + + private async Task<(bool Success, string? Url)> ExecuteWithTimeout( + Func<string, CancellationToken, Task<(bool, string?)>> func, + string url, + TimeSpan timeout, + CancellationToken cancellationToken) + { + try + { + return await func(url, cancellationToken).WaitAsync(timeout, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (TimeoutException) + { + _logger.LogWarning("Provider {Provider} timed out after {Timeout} seconds", + func.Method.Name.Replace("TryGetWith", "").Replace("Async", ""), + timeout.TotalSeconds); + return (false, null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Provider {Provider} failed", + func.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); + return (false, null); + } + } + + private async Task<(bool Success, string? Url)> TryGetWithYtDlpAsync(string url, CancellationToken cancellationToken) + { + try + { + var ytdl = new YoutubeDL { YoutubeDLPath = "yt-dlp" }; + var overrideOptions = new YoutubeDLSharp.Options.OptionSet + { + Format = "bestaudio/best" + }; + var result = await ytdl.RunVideoDataFetch(url, ct: cancellationToken, overrideOptions: overrideOptions); + + if (!result.Success || result.Data?.Formats == null) + { + _logger.LogWarning("YT-DLP fetch failed for: {Url}. Errors: {Errors}", url, + string.Join("; ", result.ErrorOutput ?? [])); + return (false, null); + } + + var httpsFormats = result.Data.Formats + .Where(f => f.Protocol != "mhtml") + .AsQueryable(); + + var bestAudio = httpsFormats + .MaxBy(f => f.AudioBitrate ?? 0) + ?? httpsFormats + .MaxBy(f => f.Bitrate ?? 0); + + if (bestAudio?.Url == null) + { + _logger.LogWarning("YT-DLP found no suitable audio format for: {Url}", url); + return (false, null); + } + + return (true, bestAudio.Url); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "YT-DLP failed for: {Url}", url); + return (false, null); + } + } + + private async Task<(bool Success, string? Url)> TryGetWithYoutubeExplodeAsync(string url, CancellationToken cancellationToken) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var youtubeClient = scope.ServiceProvider.GetRequiredService<YoutubeClient>(); + var manifest = await youtubeClient.Videos.Streams.GetManifestAsync(url, cancellationToken); + var audioStream = manifest.GetAudioOnlyStreams().GetWithHighestBitrate(); + + return (true, audioStream.Url); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "YoutubeExplode failed for: {Url}", url); + return (false, null); + } + } + + public async Task<string> GetVideoTitleAsync(string url, CancellationToken cancellationToken) + { + return (await _youtubeClient.Videos.GetAsync(url, cancellationToken)).Title; + } +} diff --git a/src/Tests/Integration/BlacklistServiceTests.cs b/src/Tests/Integration/BlacklistServiceTests.cs index cffe9a9..288e60e 100644 --- a/src/Tests/Integration/BlacklistServiceTests.cs +++ b/src/Tests/Integration/BlacklistServiceTests.cs @@ -1,116 +1,116 @@ -using Domain.Entities; -using Infrastructure.Services; -using Microsoft.EntityFrameworkCore; -using Xunit; - -namespace Tests.Integration; - -[Collection("postgres")] -public class BlacklistServiceTests(PostgresFixture fixture) -{ - private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; - - private async Task<Song> SeedSongAsync(string url, string title, bool blacklisted = false) - { - await using var context = fixture.CreateContext(); - var song = Song.Create(url, title); - if (blacklisted) - { - Song.MarkAsBlacklisted(song, true); - } - - context.Songs.Add(song); - await context.SaveChangesAsync(); - return song; - } - - [Fact] - public async Task AddToBlacklist_Marks_Existing_Song_And_Returns_True() - { - var url = UniqueUrl(); - await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); - - await using (var context = fixture.CreateContext()) - { - var service = new BlacklistService(context); - Assert.True(await service.AddToBlacklistAsync(url)); - } - - await using (var context = fixture.CreateContext()) - { - var service = new BlacklistService(context); - Assert.True(await service.IsBlacklistedAsync(url)); - } - } - - [Fact] - public async Task AddToBlacklist_Returns_False_When_Song_Not_Found() - { - await using var context = fixture.CreateContext(); - var service = new BlacklistService(context); - - Assert.False(await service.AddToBlacklistAsync(UniqueUrl())); - } - - [Fact] - public async Task RemoveFromBlacklist_By_Partial_Title_Returns_True() - { - var url = UniqueUrl(); - var marker = Guid.NewGuid().ToString("N"); - await SeedSongAsync(url, $"Some Great Song {marker}", blacklisted: true); - - await using (var context = fixture.CreateContext()) - { - var service = new BlacklistService(context); - Assert.True(await service.RemoveFromBlacklistAsync(marker)); - } - - await using (var context = fixture.CreateContext()) - { - var service = new BlacklistService(context); - Assert.False(await service.IsBlacklistedAsync(url)); - } - } - - [Fact] - public async Task RemoveFromBlacklist_Returns_False_When_Not_Found() - { - await using var context = fixture.CreateContext(); - var service = new BlacklistService(context); - - Assert.False(await service.RemoveFromBlacklistAsync(Guid.NewGuid().ToString("N"))); - } - - [Fact] - public async Task RemoveFromBlacklist_Treats_Like_Wildcards_As_Literals() - { - var url = UniqueUrl(); - var marker = Guid.NewGuid().ToString("N"); - await SeedSongAsync(url, $"Wildcard {marker}", blacklisted: true); - - await using var context = fixture.CreateContext(); - var service = new BlacklistService(context); - - // "%" would match everything if it were not escaped; it must not match this song. - Assert.False(await service.RemoveFromBlacklistAsync($"{marker}%extra")); - Assert.True(await service.IsBlacklistedAsync(url)); - } - - [Fact] - public async Task IsBlacklisted_Reflects_Flag() - { - var url = UniqueUrl(); - await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); - - await using var context = fixture.CreateContext(); - var service = new BlacklistService(context); - - Assert.False(await service.IsBlacklistedAsync(url)); - - var song = await context.Songs.FirstAsync(s => s.SourceUrl == url); - Song.MarkAsBlacklisted(song, true); - await context.SaveChangesAsync(); - - Assert.True(await service.IsBlacklistedAsync(url)); - } -} +using Domain.Entities; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class BlacklistServiceTests(PostgresFixture fixture) +{ + private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; + + private async Task<Song> SeedSongAsync(string url, string title, bool blacklisted = false) + { + await using var context = fixture.CreateContext(); + var song = Song.Create(url, title); + if (blacklisted) + { + Song.MarkAsBlacklisted(song, true); + } + + context.Songs.Add(song); + await context.SaveChangesAsync(); + return song; + } + + [Fact] + public async Task AddToBlacklist_Marks_Existing_Song_And_Returns_True() + { + var url = UniqueUrl(); + await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.AddToBlacklistAsync(url)); + } + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.IsBlacklistedAsync(url)); + } + } + + [Fact] + public async Task AddToBlacklist_Returns_False_When_Song_Not_Found() + { + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.AddToBlacklistAsync(UniqueUrl())); + } + + [Fact] + public async Task RemoveFromBlacklist_By_Partial_Title_Returns_True() + { + var url = UniqueUrl(); + var marker = Guid.NewGuid().ToString("N"); + await SeedSongAsync(url, $"Some Great Song {marker}", blacklisted: true); + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.RemoveFromBlacklistAsync(marker)); + } + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.False(await service.IsBlacklistedAsync(url)); + } + } + + [Fact] + public async Task RemoveFromBlacklist_Returns_False_When_Not_Found() + { + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.RemoveFromBlacklistAsync(Guid.NewGuid().ToString("N"))); + } + + [Fact] + public async Task RemoveFromBlacklist_Treats_Like_Wildcards_As_Literals() + { + var url = UniqueUrl(); + var marker = Guid.NewGuid().ToString("N"); + await SeedSongAsync(url, $"Wildcard {marker}", blacklisted: true); + + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + // "%" would match everything if it were not escaped; it must not match this song. + Assert.False(await service.RemoveFromBlacklistAsync($"{marker}%extra")); + Assert.True(await service.IsBlacklistedAsync(url)); + } + + [Fact] + public async Task IsBlacklisted_Reflects_Flag() + { + var url = UniqueUrl(); + await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); + + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.IsBlacklistedAsync(url)); + + var song = await context.Songs.FirstAsync(s => s.SourceUrl == url); + Song.MarkAsBlacklisted(song, true); + await context.SaveChangesAsync(); + + Assert.True(await service.IsBlacklistedAsync(url)); + } +} diff --git a/src/Tests/Integration/PostgresFixture.cs b/src/Tests/Integration/PostgresFixture.cs index e968607..e2b55ec 100644 --- a/src/Tests/Integration/PostgresFixture.cs +++ b/src/Tests/Integration/PostgresFixture.cs @@ -1,41 +1,41 @@ -using Infrastructure.Data; -using Microsoft.EntityFrameworkCore; -using Testcontainers.PostgreSql; -using Xunit; - -namespace Tests.Integration; - -/// <summary> -/// Spins up one PostgreSQL container for the whole integration collection and -/// applies the real EF Core migrations. Tests share the database, so every -/// test must use unique identifiers (URLs, usernames, user ids). -/// Requires a local Docker daemon. -/// </summary> -public sealed class PostgresFixture : IAsyncLifetime -{ - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() - .WithImage("postgres:17-alpine") - .Build(); - - public async Task InitializeAsync() - { - await _container.StartAsync(); - - await using var context = CreateContext(); - await context.Database.MigrateAsync(); - } - - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); - - public DiscordBotContext CreateContext() - { - var options = new DbContextOptionsBuilder<DiscordBotContext>() - .UseNpgsql(_container.GetConnectionString()) - .Options; - - return new DiscordBotContext(options); - } -} - -[CollectionDefinition("postgres")] -public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Tests.Integration; + +/// <summary> +/// Spins up one PostgreSQL container for the whole integration collection and +/// applies the real EF Core migrations. Tests share the database, so every +/// test must use unique identifiers (URLs, usernames, user ids). +/// Requires a local Docker daemon. +/// </summary> +public sealed class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:17-alpine") + .Build(); + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + } + + public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + public DiscordBotContext CreateContext() + { + var options = new DbContextOptionsBuilder<DiscordBotContext>() + .UseNpgsql(_container.GetConnectionString()) + .Options; + + return new DiscordBotContext(options); + } +} + +[CollectionDefinition("postgres")] +public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>; diff --git a/src/Tests/Integration/StatisticsServiceTests.cs b/src/Tests/Integration/StatisticsServiceTests.cs index 1c63bb4..4832ddd 100644 --- a/src/Tests/Integration/StatisticsServiceTests.cs +++ b/src/Tests/Integration/StatisticsServiceTests.cs @@ -1,118 +1,118 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Infrastructure.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using Xunit; - -namespace Tests.Integration; - -[Collection("postgres")] -public class StatisticsServiceTests(PostgresFixture fixture) -{ - private static ulong UniqueUserId() => - (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); - - private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; - - [Fact] - public async Task LogSongPlay_Creates_User_Song_And_PlayHistory() - { - var userId = UniqueUserId(); - var userName = $"user-{Guid.NewGuid():N}"; - var url = UniqueUrl(); - var title = $"Song {Guid.NewGuid():N}"; - var streamService = Substitute.For<IStreamService>(); - - await using (var context = fixture.CreateContext()) - { - var service = new StatisticsService(context, streamService, - NullLogger<StatisticsService>.Instance); - await service.LogSongPlayAsync(userId, userName, "Global Name", - new SongDtoBase { Url = url, Title = title, UserId = userId }); - } - - await using (var context = fixture.CreateContext()) - { - var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId); - Assert.NotNull(user); - Assert.Equal(userName, user.Username); - Assert.Equal(1, user.TotalSongsPlayed); - - var song = await context.Songs.FirstOrDefaultAsync(s => s.SourceUrl == url); - Assert.NotNull(song); - Assert.Equal(title, song.Title); - - var history = await context.PlayHistory - .FirstOrDefaultAsync(ph => ph.UserId == userId && ph.SongId == song.Id); - Assert.NotNull(history); - Assert.Equal(1, history.TotalPlays); - } - } - - [Fact] - public async Task LogSongPlay_Increments_TotalPlays_For_Repeat_Play() - { - var userId = UniqueUserId(); - var userName = $"user-{Guid.NewGuid():N}"; - var url = UniqueUrl(); - var title = $"Song {Guid.NewGuid():N}"; - var streamService = Substitute.For<IStreamService>(); - var songDto = new SongDtoBase { Url = url, Title = title, UserId = userId }; - - await using (var context = fixture.CreateContext()) - { - var service = new StatisticsService(context, streamService, - NullLogger<StatisticsService>.Instance); - await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); - } - - await using (var context = fixture.CreateContext()) - { - var service = new StatisticsService(context, streamService, - NullLogger<StatisticsService>.Instance); - await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); - } - - await using (var context = fixture.CreateContext()) - { - var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); - var histories = await context.PlayHistory - .Where(ph => ph.UserId == userId && ph.SongId == song.Id) - .ToListAsync(); - - // Deduplicated: one history row whose counter was incremented. - var history = Assert.Single(histories); - Assert.Equal(2, history.TotalPlays); - - var user = await context.Users.SingleAsync(u => u.Id == userId); - Assert.Equal(2, user.TotalSongsPlayed); - } - } - - [Fact] - public async Task LogSongPlay_Uses_Provided_Title_Without_Calling_StreamService() - { - var userId = UniqueUserId(); - var url = UniqueUrl(); - var title = $"Radio Station {Guid.NewGuid():N}"; - var streamService = Substitute.For<IStreamService>(); - - await using (var context = fixture.CreateContext()) - { - var service = new StatisticsService(context, streamService, - NullLogger<StatisticsService>.Instance); - await service.LogSongPlayAsync(userId, $"user-{Guid.NewGuid():N}", string.Empty, - new SongDtoBase { Url = url, Title = title, UserId = userId }); - } - - await streamService.DidNotReceiveWithAnyArgs().GetVideoTitleAsync(default!, default); - - await using (var context = fixture.CreateContext()) - { - var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); - Assert.Equal(title, song.Title); - } - } -} +using Application.DTOs; +using Application.Interfaces.Services; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class StatisticsServiceTests(PostgresFixture fixture) +{ + private static ulong UniqueUserId() => + (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); + + private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; + + [Fact] + public async Task LogSongPlay_Creates_User_Song_And_PlayHistory() + { + var userId = UniqueUserId(); + var userName = $"user-{Guid.NewGuid():N}"; + var url = UniqueUrl(); + var title = $"Song {Guid.NewGuid():N}"; + var streamService = Substitute.For<IStreamService>(); + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger<StatisticsService>.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", + new SongDtoBase { Url = url, Title = title, UserId = userId }); + } + + await using (var context = fixture.CreateContext()) + { + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId); + Assert.NotNull(user); + Assert.Equal(userName, user.Username); + Assert.Equal(1, user.TotalSongsPlayed); + + var song = await context.Songs.FirstOrDefaultAsync(s => s.SourceUrl == url); + Assert.NotNull(song); + Assert.Equal(title, song.Title); + + var history = await context.PlayHistory + .FirstOrDefaultAsync(ph => ph.UserId == userId && ph.SongId == song.Id); + Assert.NotNull(history); + Assert.Equal(1, history.TotalPlays); + } + } + + [Fact] + public async Task LogSongPlay_Increments_TotalPlays_For_Repeat_Play() + { + var userId = UniqueUserId(); + var userName = $"user-{Guid.NewGuid():N}"; + var url = UniqueUrl(); + var title = $"Song {Guid.NewGuid():N}"; + var streamService = Substitute.For<IStreamService>(); + var songDto = new SongDtoBase { Url = url, Title = title, UserId = userId }; + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger<StatisticsService>.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); + } + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger<StatisticsService>.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); + } + + await using (var context = fixture.CreateContext()) + { + var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); + var histories = await context.PlayHistory + .Where(ph => ph.UserId == userId && ph.SongId == song.Id) + .ToListAsync(); + + // Deduplicated: one history row whose counter was incremented. + var history = Assert.Single(histories); + Assert.Equal(2, history.TotalPlays); + + var user = await context.Users.SingleAsync(u => u.Id == userId); + Assert.Equal(2, user.TotalSongsPlayed); + } + } + + [Fact] + public async Task LogSongPlay_Uses_Provided_Title_Without_Calling_StreamService() + { + var userId = UniqueUserId(); + var url = UniqueUrl(); + var title = $"Radio Station {Guid.NewGuid():N}"; + var streamService = Substitute.For<IStreamService>(); + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger<StatisticsService>.Instance); + await service.LogSongPlayAsync(userId, $"user-{Guid.NewGuid():N}", string.Empty, + new SongDtoBase { Url = url, Title = title, UserId = userId }); + } + + await streamService.DidNotReceiveWithAnyArgs().GetVideoTitleAsync(default!, default); + + await using (var context = fixture.CreateContext()) + { + var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); + Assert.Equal(title, song.Title); + } + } +} diff --git a/src/Tests/Integration/UserServiceTests.cs b/src/Tests/Integration/UserServiceTests.cs index e9d2d2c..5f94575 100644 --- a/src/Tests/Integration/UserServiceTests.cs +++ b/src/Tests/Integration/UserServiceTests.cs @@ -1,80 +1,80 @@ -using Domain.Entities; -using Infrastructure.Services; -using Xunit; - -namespace Tests.Integration; - -[Collection("postgres")] -public class UserServiceTests(PostgresFixture fixture) -{ - private static ulong UniqueUserId() => - (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); - - [Fact] - public async Task GetAllUsers_Returns_Null_LastPlayed_For_User_With_No_History() - { - var username = $"user-{Guid.NewGuid():N}"; - - await using (var context = fixture.CreateContext()) - { - context.Users.Add(User.Create(UniqueUserId(), username, "No History")); - await context.SaveChangesAsync(); - } - - await using (var freshContext = fixture.CreateContext()) - { - var service = new UserService(freshContext); - var users = await service.GetAllUsersAsync(); - - var user = users.SingleOrDefault(u => u.Username == username); - Assert.NotNull(user); - Assert.Null(user.LastPlayed); - Assert.Equal(0, user.TotalPlays); - } - } - - [Fact] - public async Task GetAllUsers_Orders_By_TotalPlays_Descending() - { - var lightUserName = $"user-{Guid.NewGuid():N}"; - var heavyUserName = $"user-{Guid.NewGuid():N}"; - - await using (var context = fixture.CreateContext()) - { - var lightUser = User.Create(UniqueUserId(), lightUserName, "Light"); - var heavyUser = User.Create(UniqueUserId(), heavyUserName, "Heavy"); - context.Users.AddRange(lightUser, heavyUser); - - var lightSong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Light Song"); - var heavySong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Heavy Song"); - context.Songs.AddRange(lightSong, heavySong); - await context.SaveChangesAsync(); - - context.PlayHistory.Add(PlayHistory.Create(DateTimeOffset.UtcNow, lightUser.Id, lightSong.Id)); - - var heavyHistory = PlayHistory.Create(DateTimeOffset.UtcNow, heavyUser.Id, heavySong.Id); - PlayHistory.UpdateTotalPlays(heavyHistory); - PlayHistory.UpdateTotalPlays(heavyHistory); - context.PlayHistory.Add(heavyHistory); - - await context.SaveChangesAsync(); - } - - await using (var freshContext = fixture.CreateContext()) - { - var service = new UserService(freshContext); - var users = (await service.GetAllUsersAsync()).ToList(); - - var heavyIndex = users.FindIndex(u => u.Username == heavyUserName); - var lightIndex = users.FindIndex(u => u.Username == lightUserName); - - Assert.True(heavyIndex >= 0); - Assert.True(lightIndex >= 0); - Assert.True(heavyIndex < lightIndex, - "User with more plays should be ordered before user with fewer plays."); - - Assert.Equal(3, users[heavyIndex].TotalPlays); - Assert.NotNull(users[heavyIndex].LastPlayed); - } - } -} +using Domain.Entities; +using Infrastructure.Services; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class UserServiceTests(PostgresFixture fixture) +{ + private static ulong UniqueUserId() => + (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); + + [Fact] + public async Task GetAllUsers_Returns_Null_LastPlayed_For_User_With_No_History() + { + var username = $"user-{Guid.NewGuid():N}"; + + await using (var context = fixture.CreateContext()) + { + context.Users.Add(User.Create(UniqueUserId(), username, "No History")); + await context.SaveChangesAsync(); + } + + await using (var freshContext = fixture.CreateContext()) + { + var service = new UserService(freshContext); + var users = await service.GetAllUsersAsync(); + + var user = users.SingleOrDefault(u => u.Username == username); + Assert.NotNull(user); + Assert.Null(user.LastPlayed); + Assert.Equal(0, user.TotalPlays); + } + } + + [Fact] + public async Task GetAllUsers_Orders_By_TotalPlays_Descending() + { + var lightUserName = $"user-{Guid.NewGuid():N}"; + var heavyUserName = $"user-{Guid.NewGuid():N}"; + + await using (var context = fixture.CreateContext()) + { + var lightUser = User.Create(UniqueUserId(), lightUserName, "Light"); + var heavyUser = User.Create(UniqueUserId(), heavyUserName, "Heavy"); + context.Users.AddRange(lightUser, heavyUser); + + var lightSong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Light Song"); + var heavySong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Heavy Song"); + context.Songs.AddRange(lightSong, heavySong); + await context.SaveChangesAsync(); + + context.PlayHistory.Add(PlayHistory.Create(DateTimeOffset.UtcNow, lightUser.Id, lightSong.Id)); + + var heavyHistory = PlayHistory.Create(DateTimeOffset.UtcNow, heavyUser.Id, heavySong.Id); + PlayHistory.UpdateTotalPlays(heavyHistory); + PlayHistory.UpdateTotalPlays(heavyHistory); + context.PlayHistory.Add(heavyHistory); + + await context.SaveChangesAsync(); + } + + await using (var freshContext = fixture.CreateContext()) + { + var service = new UserService(freshContext); + var users = (await service.GetAllUsersAsync()).ToList(); + + var heavyIndex = users.FindIndex(u => u.Username == heavyUserName); + var lightIndex = users.FindIndex(u => u.Username == lightUserName); + + Assert.True(heavyIndex >= 0); + Assert.True(lightIndex >= 0); + Assert.True(heavyIndex < lightIndex, + "User with more plays should be ordered before user with fewer plays."); + + Assert.Equal(3, users[heavyIndex].TotalPlays); + Assert.NotNull(users[heavyIndex].LastPlayed); + } + } +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index d37e792..7748b6e 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -1,24 +1,24 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <IsPackable>false</IsPackable> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" /> - <PackageReference Include="xunit" /> - <PackageReference Include="xunit.runner.visualstudio"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="NSubstitute" /> - <PackageReference Include="Testcontainers.PostgreSql" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\Domain\Domain.csproj" /> - <ProjectReference Include="..\Application\Application.csproj" /> - <ProjectReference Include="..\Infrastructure\Infrastructure.csproj" /> - </ItemGroup> - -</Project> +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" /> + <PackageReference Include="xunit" /> + <PackageReference Include="xunit.runner.visualstudio"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="NSubstitute" /> + <PackageReference Include="Testcontainers.PostgreSql" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Domain\Domain.csproj" /> + <ProjectReference Include="..\Application\Application.csproj" /> + <ProjectReference Include="..\Infrastructure\Infrastructure.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Tests/Unit/EventingTests.cs b/src/Tests/Unit/EventingTests.cs index 022f2ea..d336555 100644 --- a/src/Tests/Unit/EventingTests.cs +++ b/src/Tests/Unit/EventingTests.cs @@ -1,80 +1,80 @@ -using Application.Eventing; -using Application.Interfaces.Services; -using Domain.Common; -using Domain.Eventing; -using Domain.Events; -using Infrastructure.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using Xunit; - -namespace Tests.Unit; - -public class EventingTests -{ - [Fact] - public void AddEventing_Registers_PlayerHandler_As_Sync_Handler_For_Skip_And_Stop() - { - var services = new ServiceCollection(); - services.AddEventing( - typeof(Application.AssemblyMarker).Assembly, - typeof(Infrastructure.Services.AssemblyMarker).Assembly); - - using var provider = services.BuildServiceProvider(); - var registry = provider.GetRequiredService<HandlerRegistry>(); - - Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Skip))); - Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Stop))); - - // The Play event is no longer handled; enqueueing wakes the background consumer directly. - Assert.Empty(registry.GetSyncHandlers(typeof(EventType.Play))); - Assert.Empty(registry.GetAsyncHandlers(typeof(EventType.Play))); - } - - [Fact] - public void EventDispatcher_Dispatch_Invokes_Registered_Sync_Handler() - { - var services = new ServiceCollection(); - services.AddSingleton<EventRecorder>(); - services.AddEventing(typeof(EventingTests).Assembly); - - using var provider = services.BuildServiceProvider(); - using var scope = provider.CreateScope(); - - var dispatcher = scope.ServiceProvider.GetRequiredService<IEventDispatcher>(); - dispatcher.Dispatch(new TestEvent()); - dispatcher.Dispatch(new TestEvent()); - - var recorder = provider.GetRequiredService<EventRecorder>(); - Assert.Equal(2, recorder.Count); - } - - [Fact] - public void PlayerHandler_Forwards_Guild_Scoped_Skip_And_Stop() - { - var guildMusicService = Substitute.For<IGuildMusicService>(); - var handler = new PlayerHandler(guildMusicService, NullLogger<PlayerHandler>.Instance); - - handler.Handle(new EventType.Skip(42)); - handler.Handle(new EventType.Stop(99)); - - guildMusicService.Received(1).Skip(42); - guildMusicService.Received(1).Stop(99); - } -} - -public sealed record TestEvent : IEvent; - -public sealed class EventRecorder -{ - public int Count; -} - -public sealed class TestEventHandler(EventRecorder recorder) : IEventHandler<TestEvent> -{ - public void Handle(TestEvent @event) - { - Interlocked.Increment(ref recorder.Count); - } -} +using Application.Eventing; +using Application.Interfaces.Services; +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Tests.Unit; + +public class EventingTests +{ + [Fact] + public void AddEventing_Registers_PlayerHandler_As_Sync_Handler_For_Skip_And_Stop() + { + var services = new ServiceCollection(); + services.AddEventing( + typeof(Application.AssemblyMarker).Assembly, + typeof(Infrastructure.Services.AssemblyMarker).Assembly); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService<HandlerRegistry>(); + + Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Skip))); + Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Stop))); + + // The Play event is no longer handled; enqueueing wakes the background consumer directly. + Assert.Empty(registry.GetSyncHandlers(typeof(EventType.Play))); + Assert.Empty(registry.GetAsyncHandlers(typeof(EventType.Play))); + } + + [Fact] + public void EventDispatcher_Dispatch_Invokes_Registered_Sync_Handler() + { + var services = new ServiceCollection(); + services.AddSingleton<EventRecorder>(); + services.AddEventing(typeof(EventingTests).Assembly); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var dispatcher = scope.ServiceProvider.GetRequiredService<IEventDispatcher>(); + dispatcher.Dispatch(new TestEvent()); + dispatcher.Dispatch(new TestEvent()); + + var recorder = provider.GetRequiredService<EventRecorder>(); + Assert.Equal(2, recorder.Count); + } + + [Fact] + public void PlayerHandler_Forwards_Guild_Scoped_Skip_And_Stop() + { + var guildMusicService = Substitute.For<IGuildMusicService>(); + var handler = new PlayerHandler(guildMusicService, NullLogger<PlayerHandler>.Instance); + + handler.Handle(new EventType.Skip(42)); + handler.Handle(new EventType.Stop(99)); + + guildMusicService.Received(1).Skip(42); + guildMusicService.Received(1).Stop(99); + } +} + +public sealed record TestEvent : IEvent; + +public sealed class EventRecorder +{ + public int Count; +} + +public sealed class TestEventHandler(EventRecorder recorder) : IEventHandler<TestEvent> +{ + public void Handle(TestEvent @event) + { + Interlocked.Increment(ref recorder.Count); + } +} diff --git a/src/Tests/Unit/GuildPlayerManagerTests.cs b/src/Tests/Unit/GuildPlayerManagerTests.cs index f79bff1..da0e8f0 100644 --- a/src/Tests/Unit/GuildPlayerManagerTests.cs +++ b/src/Tests/Unit/GuildPlayerManagerTests.cs @@ -1,214 +1,214 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Infrastructure.Services; -using Microsoft.Extensions.Logging.Abstractions; -using NetCord.Services.ComponentInteractions; -using NSubstitute; -using Xunit; - -namespace Tests.Unit; - -public class GuildPlayerManagerTests -{ - private const ulong GuildA = 1111; - private const ulong GuildB = 2222; - - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - - private static PlayRequest<StringMenuInteractionContext> CreateRequest() - { - return new PlayRequest<StringMenuInteractionContext> - { - Callbacks = _ => Task.CompletedTask - }; - } - - private static GuildPlayerManager CreateManager( - Func<ulong, INetCordAudioPlayerService> audioPlayerFactory, - Action<ulong>? onCreate = null) - { - return new GuildPlayerManager(NullLoggerFactory.Instance, guildId => - { - onCreate?.Invoke(guildId); - return new GuildPlayerComponents(new MusicQueueService(), audioPlayerFactory(guildId)); - }); - } - - /// <summary> - /// An audio player whose track signals when it starts and then plays until it is - /// either released (Completed) or its token is cancelled (Skipped). - /// </summary> - private static INetCordAudioPlayerService CreateBlockingAudioPlayer( - TaskCompletionSource started, TaskCompletionSource release) - { - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(async callInfo => - { - var token = callInfo.Arg<CancellationToken>(); - started.TrySetResult(); - var cancelled = new TaskCompletionSource(); - await using var registration = token.Register(() => cancelled.TrySetResult()); - var finished = await Task.WhenAny(release.Task, cancelled.Task).WaitAsync(Timeout); - return finished == cancelled.Task ? TrackPlayResult.Skipped : TrackPlayResult.Completed; - }); - return audioPlayer; - } - - private static async Task WaitUntilAsync(Func<bool> condition, string description) - { - var start = Environment.TickCount64; - while (!condition()) - { - if (Environment.TickCount64 - start > Timeout.TotalMilliseconds) - { - throw new TimeoutException($"Condition not met within timeout: {description}"); - } - - await Task.Delay(25); - } - } - - [Fact] - public async Task Tracks_In_Different_Guilds_Play_Concurrently() - { - var startedA = new TaskCompletionSource(); - var releaseA = new TaskCompletionSource(); - var startedB = new TaskCompletionSource(); - var releaseB = new TaskCompletionSource(); - - using var manager = CreateManager(guildId => guildId == GuildA - ? CreateBlockingAudioPlayer(startedA, releaseA) - : CreateBlockingAudioPlayer(startedB, releaseB)); - await manager.StartAsync(CancellationToken.None); - try - { - manager.Enqueue(GuildA, CreateRequest()); - await startedA.Task.WaitAsync(Timeout); - - // Guild A's track is still held open; guild B's track must start anyway. - manager.Enqueue(GuildB, CreateRequest()); - await startedB.Task.WaitAsync(Timeout); - - Assert.NotNull(manager.GetNowPlaying(GuildA)); - Assert.NotNull(manager.GetNowPlaying(GuildB)); - - releaseA.TrySetResult(); - releaseB.TrySetResult(); - } - finally - { - await manager.StopAsync(CancellationToken.None); - } - } - - [Fact] - public async Task Skip_Cancels_Only_That_Guilds_Track() - { - var startedA = new TaskCompletionSource(); - var releaseA = new TaskCompletionSource(); - var startedB = new TaskCompletionSource(); - var releaseB = new TaskCompletionSource(); - - using var manager = CreateManager(guildId => guildId == GuildA - ? CreateBlockingAudioPlayer(startedA, releaseA) - : CreateBlockingAudioPlayer(startedB, releaseB)); - await manager.StartAsync(CancellationToken.None); - try - { - manager.Enqueue(GuildA, CreateRequest()); - manager.Enqueue(GuildB, CreateRequest()); - await startedA.Task.WaitAsync(Timeout); - await startedB.Task.WaitAsync(Timeout); - - manager.Skip(GuildA); - - await WaitUntilAsync(() => manager.GetNowPlaying(GuildA) is null, "guild A track skipped"); - Assert.NotNull(manager.GetNowPlaying(GuildB)); - - releaseB.TrySetResult(); - } - finally - { - await manager.StopAsync(CancellationToken.None); - } - } - - [Fact] - public async Task Stop_Clears_Only_That_Guilds_Queue() - { - var startedA = new TaskCompletionSource(); - var releaseA = new TaskCompletionSource(); - var startedB = new TaskCompletionSource(); - var releaseB = new TaskCompletionSource(); - - using var manager = CreateManager(guildId => guildId == GuildA - ? CreateBlockingAudioPlayer(startedA, releaseA) - : CreateBlockingAudioPlayer(startedB, releaseB)); - await manager.StartAsync(CancellationToken.None); - try - { - manager.Enqueue(GuildA, CreateRequest()); - manager.Enqueue(GuildA, CreateRequest()); - manager.Enqueue(GuildB, CreateRequest()); - manager.Enqueue(GuildB, CreateRequest()); - await startedA.Task.WaitAsync(Timeout); - await startedB.Task.WaitAsync(Timeout); - - manager.Stop(GuildA); - - await WaitUntilAsync( - () => manager.GetNowPlaying(GuildA) is null && manager.GetQueueCount(GuildA) == 0, - "guild A stopped and cleared"); - - // Guild B is untouched: still playing its first track with one pending. - Assert.NotNull(manager.GetNowPlaying(GuildB)); - Assert.Equal(1, manager.GetQueueCount(GuildB)); - - releaseB.TrySetResult(); - } - finally - { - await manager.StopAsync(CancellationToken.None); - } - } - - [Fact] - public void Operations_On_Unknown_Guild_Are_NoOps() - { - using var manager = CreateManager(_ => Substitute.For<INetCordAudioPlayerService>()); - - Assert.Null(manager.GetNowPlaying(GuildA)); - Assert.Empty(manager.GetAllRequests(GuildA)); - Assert.Equal(0, manager.GetQueueCount(GuildA)); - - // None of these should throw or create a player. - manager.Skip(GuildA); - manager.Stop(GuildA); - manager.Rewind(GuildA); - - Assert.Null(manager.GetNowPlaying(GuildA)); - } - - [Fact] - public async Task Same_Guild_Reuses_The_Same_Player() - { - var factoryCalls = 0; - using var manager = CreateManager( - _ => Substitute.For<INetCordAudioPlayerService>(), - _ => Interlocked.Increment(ref factoryCalls)); - await manager.StartAsync(CancellationToken.None); - try - { - manager.Enqueue(GuildA, CreateRequest()); - manager.Enqueue(GuildA, CreateRequest()); - manager.Enqueue(GuildB, CreateRequest()); - - Assert.Equal(2, Volatile.Read(ref factoryCalls)); - } - finally - { - await manager.StopAsync(CancellationToken.None); - } - } -} +using Application.DTOs; +using Application.Interfaces.Services; +using Infrastructure.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NetCord.Services.ComponentInteractions; +using NSubstitute; +using Xunit; + +namespace Tests.Unit; + +public class GuildPlayerManagerTests +{ + private const ulong GuildA = 1111; + private const ulong GuildB = 2222; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest<StringMenuInteractionContext> CreateRequest() + { + return new PlayRequest<StringMenuInteractionContext> + { + Callbacks = _ => Task.CompletedTask + }; + } + + private static GuildPlayerManager CreateManager( + Func<ulong, INetCordAudioPlayerService> audioPlayerFactory, + Action<ulong>? onCreate = null) + { + return new GuildPlayerManager(NullLoggerFactory.Instance, guildId => + { + onCreate?.Invoke(guildId); + return new GuildPlayerComponents(new MusicQueueService(), audioPlayerFactory(guildId)); + }); + } + + /// <summary> + /// An audio player whose track signals when it starts and then plays until it is + /// either released (Completed) or its token is cancelled (Skipped). + /// </summary> + private static INetCordAudioPlayerService CreateBlockingAudioPlayer( + TaskCompletionSource started, TaskCompletionSource release) + { + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(async callInfo => + { + var token = callInfo.Arg<CancellationToken>(); + started.TrySetResult(); + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + var finished = await Task.WhenAny(release.Task, cancelled.Task).WaitAsync(Timeout); + return finished == cancelled.Task ? TrackPlayResult.Skipped : TrackPlayResult.Completed; + }); + return audioPlayer; + } + + private static async Task WaitUntilAsync(Func<bool> condition, string description) + { + var start = Environment.TickCount64; + while (!condition()) + { + if (Environment.TickCount64 - start > Timeout.TotalMilliseconds) + { + throw new TimeoutException($"Condition not met within timeout: {description}"); + } + + await Task.Delay(25); + } + } + + [Fact] + public async Task Tracks_In_Different_Guilds_Play_Concurrently() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + + // Guild A's track is still held open; guild B's track must start anyway. + manager.Enqueue(GuildB, CreateRequest()); + await startedB.Task.WaitAsync(Timeout); + + Assert.NotNull(manager.GetNowPlaying(GuildA)); + Assert.NotNull(manager.GetNowPlaying(GuildB)); + + releaseA.TrySetResult(); + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Skip_Cancels_Only_That_Guilds_Track() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + await startedB.Task.WaitAsync(Timeout); + + manager.Skip(GuildA); + + await WaitUntilAsync(() => manager.GetNowPlaying(GuildA) is null, "guild A track skipped"); + Assert.NotNull(manager.GetNowPlaying(GuildB)); + + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Stop_Clears_Only_That_Guilds_Queue() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + await startedB.Task.WaitAsync(Timeout); + + manager.Stop(GuildA); + + await WaitUntilAsync( + () => manager.GetNowPlaying(GuildA) is null && manager.GetQueueCount(GuildA) == 0, + "guild A stopped and cleared"); + + // Guild B is untouched: still playing its first track with one pending. + Assert.NotNull(manager.GetNowPlaying(GuildB)); + Assert.Equal(1, manager.GetQueueCount(GuildB)); + + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public void Operations_On_Unknown_Guild_Are_NoOps() + { + using var manager = CreateManager(_ => Substitute.For<INetCordAudioPlayerService>()); + + Assert.Null(manager.GetNowPlaying(GuildA)); + Assert.Empty(manager.GetAllRequests(GuildA)); + Assert.Equal(0, manager.GetQueueCount(GuildA)); + + // None of these should throw or create a player. + manager.Skip(GuildA); + manager.Stop(GuildA); + manager.Rewind(GuildA); + + Assert.Null(manager.GetNowPlaying(GuildA)); + } + + [Fact] + public async Task Same_Guild_Reuses_The_Same_Player() + { + var factoryCalls = 0; + using var manager = CreateManager( + _ => Substitute.For<INetCordAudioPlayerService>(), + _ => Interlocked.Increment(ref factoryCalls)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + + Assert.Equal(2, Volatile.Read(ref factoryCalls)); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } +} diff --git a/src/Tests/Unit/GuildPlayerTests.cs b/src/Tests/Unit/GuildPlayerTests.cs index e275112..9853f9a 100644 --- a/src/Tests/Unit/GuildPlayerTests.cs +++ b/src/Tests/Unit/GuildPlayerTests.cs @@ -1,305 +1,305 @@ -using Application.DTOs; -using Application.Interfaces.Services; -using Infrastructure.Services; -using Microsoft.Extensions.Logging.Abstractions; -using NetCord.Services.ComponentInteractions; -using NSubstitute; -using Xunit; - -namespace Tests.Unit; - -public class GuildPlayerTests -{ - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - - private static PlayRequest<StringMenuInteractionContext> CreateRequest(List<string>? messages = null) - { - return new PlayRequest<StringMenuInteractionContext> - { - Callbacks = message => - { - if (messages is not null) - { - lock (messages) - { - messages.Add(message); - } - } - - return Task.CompletedTask; - } - }; - } - - private static GuildPlayer CreatePlayer(IMusicQueueService queue, INetCordAudioPlayerService audioPlayer) - { - return new GuildPlayer(queue, audioPlayer, NullLogger.Instance); - } - - private static async Task WaitUntilAsync(Func<bool> condition, string description) - { - var start = Environment.TickCount64; - while (!condition()) - { - if (Environment.TickCount64 - start > Timeout.TotalMilliseconds) - { - throw new TimeoutException($"Condition not met within timeout: {description}"); - } - - await Task.Delay(25); - } - } - - [Fact] - public async Task Plays_Queued_Tracks_Sequentially_In_Order() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - var played = new List<Guid>(); - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(callInfo => - { - lock (played) - { - played.Add(callInfo.Arg<PlayRequest>().Id); - } - - return TrackPlayResult.Completed; - }); - - var first = CreateRequest(); - var second = CreateRequest(); - queue.Enqueue(first); - queue.Enqueue(second); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await WaitUntilAsync(() => - { - lock (played) - { - return played.Count == 2; - } - }, "both tracks played"); - - Assert.Equal([first.Id, second.Id], played); - Assert.Null(queue.NowPlaying); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } - - [Fact] - public async Task Failed_Track_Is_Retried_Up_To_Three_Attempts_Then_Dropped() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - var attempts = 0; - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(_ => - { - Interlocked.Increment(ref attempts); - return TrackPlayResult.Failed; - }); - - queue.Enqueue(CreateRequest()); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await WaitUntilAsync(() => Volatile.Read(ref attempts) == 3, "three play attempts"); - await WaitUntilAsync(() => queue.NowPlaying is null, "track dropped"); - - // Give the loop a moment to prove it does not retry a fourth time. - await Task.Delay(200); - Assert.Equal(3, Volatile.Read(ref attempts)); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } - - [Fact] - public async Task Skip_Cancels_Current_Track_And_Advances_To_Next() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - var firstStarted = new TaskCompletionSource(); - var playedSecond = new TaskCompletionSource(); - var first = CreateRequest(); - var second = CreateRequest(); - - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(async callInfo => - { - var request = callInfo.Arg<PlayRequest>(); - var token = callInfo.Arg<CancellationToken>(); - if (request.Id == first.Id) - { - firstStarted.TrySetResult(); - // Simulate a long track that only ends when cancelled. - var cancelled = new TaskCompletionSource(); - await using var registration = token.Register(() => cancelled.TrySetResult()); - await cancelled.Task.WaitAsync(Timeout); - return TrackPlayResult.Skipped; - } - - playedSecond.TrySetResult(); - return TrackPlayResult.Completed; - }); - - queue.Enqueue(first); - queue.Enqueue(second); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await firstStarted.Task.WaitAsync(Timeout); - - player.Skip(); - - await playedSecond.Task.WaitAsync(Timeout); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } - - [Fact] - public async Task Stop_Clears_Pending_Cancels_Current_And_Disconnects() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - var firstStarted = new TaskCompletionSource(); - var disconnected = new TaskCompletionSource(); - var first = CreateRequest(); - var second = CreateRequest(); - - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(async callInfo => - { - var token = callInfo.Arg<CancellationToken>(); - firstStarted.TrySetResult(); - var cancelled = new TaskCompletionSource(); - await using var registration = token.Register(() => cancelled.TrySetResult()); - await cancelled.Task.WaitAsync(Timeout); - return TrackPlayResult.Skipped; - }); - audioPlayer.DisconnectAsync().Returns(_ => - { - disconnected.TrySetResult(); - return Task.CompletedTask; - }); - - queue.Enqueue(first); - queue.Enqueue(second); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await firstStarted.Task.WaitAsync(Timeout); - - player.Stop(); - - await disconnected.Task.WaitAsync(Timeout); - Assert.Equal(0, queue.Count); - - // Only the first track was ever played; the second was cleared. - await audioPlayer.Received(1).PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } - - [Fact] - public async Task Disconnects_When_Queue_Empty_After_Last_Track() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - var disconnected = new TaskCompletionSource(); - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(TrackPlayResult.Completed); - audioPlayer.DisconnectAsync().Returns(_ => - { - disconnected.TrySetResult(); - return Task.CompletedTask; - }); - - var messages = new List<string>(); - queue.Enqueue(CreateRequest(messages)); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await disconnected.Task.WaitAsync(Timeout); - - await WaitUntilAsync(() => - { - lock (messages) - { - return messages.Count > 0; - } - }, "disconnect message sent"); - Assert.Contains("Disconnected from voice channel", messages[0]); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } - - [Fact] - public async Task NotInVoiceChannel_Result_Invokes_Request_Callback_And_Does_Not_Retry() - { - var queue = new MusicQueueService(); - var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); - audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) - .Returns(TrackPlayResult.NotInVoiceChannel); - - var messages = new List<string>(); - queue.Enqueue(CreateRequest(messages)); - - var player = CreatePlayer(queue, audioPlayer); - using var cts = new CancellationTokenSource(); - var runTask = player.RunAsync(cts.Token); - try - { - await WaitUntilAsync(() => - { - lock (messages) - { - return messages.Any(m => m.Contains("not connected to any voice channel")); - } - }, "not-in-voice-channel message sent"); - - await audioPlayer.Received(1).PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()); - } - finally - { - await cts.CancelAsync(); - await runTask; - } - } -} +using Application.DTOs; +using Application.Interfaces.Services; +using Infrastructure.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NetCord.Services.ComponentInteractions; +using NSubstitute; +using Xunit; + +namespace Tests.Unit; + +public class GuildPlayerTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest<StringMenuInteractionContext> CreateRequest(List<string>? messages = null) + { + return new PlayRequest<StringMenuInteractionContext> + { + Callbacks = message => + { + if (messages is not null) + { + lock (messages) + { + messages.Add(message); + } + } + + return Task.CompletedTask; + } + }; + } + + private static GuildPlayer CreatePlayer(IMusicQueueService queue, INetCordAudioPlayerService audioPlayer) + { + return new GuildPlayer(queue, audioPlayer, NullLogger.Instance); + } + + private static async Task WaitUntilAsync(Func<bool> condition, string description) + { + var start = Environment.TickCount64; + while (!condition()) + { + if (Environment.TickCount64 - start > Timeout.TotalMilliseconds) + { + throw new TimeoutException($"Condition not met within timeout: {description}"); + } + + await Task.Delay(25); + } + } + + [Fact] + public async Task Plays_Queued_Tracks_Sequentially_In_Order() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + var played = new List<Guid>(); + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(callInfo => + { + lock (played) + { + played.Add(callInfo.Arg<PlayRequest>().Id); + } + + return TrackPlayResult.Completed; + }); + + var first = CreateRequest(); + var second = CreateRequest(); + queue.Enqueue(first); + queue.Enqueue(second); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await WaitUntilAsync(() => + { + lock (played) + { + return played.Count == 2; + } + }, "both tracks played"); + + Assert.Equal([first.Id, second.Id], played); + Assert.Null(queue.NowPlaying); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } + + [Fact] + public async Task Failed_Track_Is_Retried_Up_To_Three_Attempts_Then_Dropped() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + var attempts = 0; + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(_ => + { + Interlocked.Increment(ref attempts); + return TrackPlayResult.Failed; + }); + + queue.Enqueue(CreateRequest()); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await WaitUntilAsync(() => Volatile.Read(ref attempts) == 3, "three play attempts"); + await WaitUntilAsync(() => queue.NowPlaying is null, "track dropped"); + + // Give the loop a moment to prove it does not retry a fourth time. + await Task.Delay(200); + Assert.Equal(3, Volatile.Read(ref attempts)); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } + + [Fact] + public async Task Skip_Cancels_Current_Track_And_Advances_To_Next() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + var firstStarted = new TaskCompletionSource(); + var playedSecond = new TaskCompletionSource(); + var first = CreateRequest(); + var second = CreateRequest(); + + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(async callInfo => + { + var request = callInfo.Arg<PlayRequest>(); + var token = callInfo.Arg<CancellationToken>(); + if (request.Id == first.Id) + { + firstStarted.TrySetResult(); + // Simulate a long track that only ends when cancelled. + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + await cancelled.Task.WaitAsync(Timeout); + return TrackPlayResult.Skipped; + } + + playedSecond.TrySetResult(); + return TrackPlayResult.Completed; + }); + + queue.Enqueue(first); + queue.Enqueue(second); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await firstStarted.Task.WaitAsync(Timeout); + + player.Skip(); + + await playedSecond.Task.WaitAsync(Timeout); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } + + [Fact] + public async Task Stop_Clears_Pending_Cancels_Current_And_Disconnects() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + var firstStarted = new TaskCompletionSource(); + var disconnected = new TaskCompletionSource(); + var first = CreateRequest(); + var second = CreateRequest(); + + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(async callInfo => + { + var token = callInfo.Arg<CancellationToken>(); + firstStarted.TrySetResult(); + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + await cancelled.Task.WaitAsync(Timeout); + return TrackPlayResult.Skipped; + }); + audioPlayer.DisconnectAsync().Returns(_ => + { + disconnected.TrySetResult(); + return Task.CompletedTask; + }); + + queue.Enqueue(first); + queue.Enqueue(second); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await firstStarted.Task.WaitAsync(Timeout); + + player.Stop(); + + await disconnected.Task.WaitAsync(Timeout); + Assert.Equal(0, queue.Count); + + // Only the first track was ever played; the second was cleared. + await audioPlayer.Received(1).PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } + + [Fact] + public async Task Disconnects_When_Queue_Empty_After_Last_Track() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + var disconnected = new TaskCompletionSource(); + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(TrackPlayResult.Completed); + audioPlayer.DisconnectAsync().Returns(_ => + { + disconnected.TrySetResult(); + return Task.CompletedTask; + }); + + var messages = new List<string>(); + queue.Enqueue(CreateRequest(messages)); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await disconnected.Task.WaitAsync(Timeout); + + await WaitUntilAsync(() => + { + lock (messages) + { + return messages.Count > 0; + } + }, "disconnect message sent"); + Assert.Contains("Disconnected from voice channel", messages[0]); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } + + [Fact] + public async Task NotInVoiceChannel_Result_Invokes_Request_Callback_And_Does_Not_Retry() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For<INetCordAudioPlayerService>(); + audioPlayer.PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()) + .Returns(TrackPlayResult.NotInVoiceChannel); + + var messages = new List<string>(); + queue.Enqueue(CreateRequest(messages)); + + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); + try + { + await WaitUntilAsync(() => + { + lock (messages) + { + return messages.Any(m => m.Contains("not connected to any voice channel")); + } + }, "not-in-voice-channel message sent"); + + await audioPlayer.Received(1).PlayTrackAsync(Arg.Any<PlayRequest>(), Arg.Any<CancellationToken>()); + } + finally + { + await cts.CancelAsync(); + await runTask; + } + } +} diff --git a/src/Tests/Unit/MusicQueueServiceTests.cs b/src/Tests/Unit/MusicQueueServiceTests.cs index a1b026a..b145712 100644 --- a/src/Tests/Unit/MusicQueueServiceTests.cs +++ b/src/Tests/Unit/MusicQueueServiceTests.cs @@ -1,187 +1,187 @@ -using Application.DTOs; -using Infrastructure.Services; -using NetCord.Services.ComponentInteractions; -using Xunit; - -namespace Tests.Unit; - -public class MusicQueueServiceTests -{ - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - - private static PlayRequest<StringMenuInteractionContext> CreateRequest(string? url = null) - { - return new PlayRequest<StringMenuInteractionContext> - { - Callbacks = _ => Task.CompletedTask, - VideoUrl = url - }; - } - - [Fact] - public async Task Enqueue_Then_Dequeue_Returns_Items_In_Fifo_Order() - { - var queue = new MusicQueueService(); - var first = CreateRequest(); - var second = CreateRequest(); - var third = CreateRequest(); - - queue.Enqueue(first); - queue.Enqueue(second); - queue.Enqueue(third); - - Assert.Equal(first.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); - Assert.Equal(second.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); - Assert.Equal(third.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); - Assert.Equal(0, queue.Count); - } - - [Fact] - public async Task Dequeue_Waits_Until_Item_Is_Enqueued() - { - var queue = new MusicQueueService(); - - var dequeueTask = queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None).AsTask(); - await Task.Delay(100); - Assert.False(dequeueTask.IsCompleted); - - var request = CreateRequest(); - queue.Enqueue(request); - - var dequeued = await dequeueTask.WaitAsync(Timeout); - Assert.Equal(request.Id, dequeued.Id); - } - - [Fact] - public async Task Dequeue_Honors_Cancellation() - { - var queue = new MusicQueueService(); - using var cts = new CancellationTokenSource(); - - var dequeueTask = queue.DequeueAsync<StringMenuInteractionContext>(cts.Token).AsTask(); - cts.Cancel(); - - await Assert.ThrowsAnyAsync<OperationCanceledException>(() => dequeueTask.WaitAsync(Timeout)); - } - - [Fact] - public async Task Clear_Removes_Pending_Items_And_Stale_Signals_Do_Not_Yield_Items() - { - var queue = new MusicQueueService(); - queue.Enqueue(CreateRequest()); - queue.Enqueue(CreateRequest()); - - queue.Clear(); - Assert.Equal(0, queue.Count); - - // The two stale signals left behind by Clear must not produce items. - var afterClear = CreateRequest(); - queue.Enqueue(afterClear); - - var dequeued = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) - .AsTask().WaitAsync(Timeout); - Assert.Equal(afterClear.Id, dequeued.Id); - - // Nothing is left: the next dequeue must block even though stale signals may remain. - var blocked = queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None).AsTask(); - await Task.Delay(100); - Assert.False(blocked.IsCompleted); - } - - [Fact] - public void Count_Excludes_NowPlaying_And_GetAllRequests_Puts_NowPlaying_First() - { - var queue = new MusicQueueService(); - var playing = CreateRequest(); - var pending = CreateRequest(); - - queue.SetNowPlaying(playing); - queue.Enqueue(pending); - - Assert.Equal(1, queue.Count); - Assert.Equal(playing.Id, queue.NowPlaying?.Id); - - var all = queue.GetAllRequests(); - Assert.Equal(2, all.Length); - Assert.Equal(playing.Id, all[0].Id); - Assert.Equal(pending.Id, all[1].Id); - - queue.SetNowPlaying(null); - Assert.Null(queue.NowPlaying); - Assert.Single(queue.GetAllRequests()); - } - - [Fact] - public async Task Rewind_Inserts_NowPlaying_At_Front_And_Signals() - { - var queue = new MusicQueueService(); - var playing = CreateRequest(); - playing.RetryCount = 2; - var pending = CreateRequest(); - - queue.SetNowPlaying(playing); - queue.Enqueue(pending); - - queue.Rewind(); - - // The rewound track is in front of the previously pending one, with retries reset. - var first = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) - .AsTask().WaitAsync(Timeout); - Assert.Equal(playing.Id, first.Id); - Assert.Equal(0, first.RetryCount); - - var second = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) - .AsTask().WaitAsync(Timeout); - Assert.Equal(pending.Id, second.Id); - } - - [Fact] - public void Rewind_Without_NowPlaying_Is_A_NoOp() - { - var queue = new MusicQueueService(); - queue.Rewind(); - Assert.Equal(0, queue.Count); - } - - [Fact] - public async Task Concurrent_Enqueues_Are_All_Dequeued_Exactly_Once() - { - var queue = new MusicQueueService(); - const int producers = 4; - const int itemsPerProducer = 25; - const int total = producers * itemsPerProducer; - - var produced = new List<Guid>(); - var producerTasks = Enumerable.Range(0, producers).Select(_ => Task.Run(() => - { - for (var i = 0; i < itemsPerProducer; i++) - { - var request = CreateRequest(); - lock (produced) - { - produced.Add(request.Id); - } - - queue.Enqueue(request); - } - })).ToArray(); - - var consumed = new List<Guid>(); - var consumerTask = Task.Run(async () => - { - for (var i = 0; i < total; i++) - { - var item = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None); - consumed.Add(item.Id); - } - }); - - await Task.WhenAll(producerTasks).WaitAsync(Timeout); - await consumerTask.WaitAsync(Timeout); - - Assert.Equal(total, consumed.Count); - Assert.Equal(consumed.Count, consumed.Distinct().Count()); - Assert.Equal(produced.OrderBy(id => id), consumed.OrderBy(id => id)); - Assert.Equal(0, queue.Count); - } -} +using Application.DTOs; +using Infrastructure.Services; +using NetCord.Services.ComponentInteractions; +using Xunit; + +namespace Tests.Unit; + +public class MusicQueueServiceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest<StringMenuInteractionContext> CreateRequest(string? url = null) + { + return new PlayRequest<StringMenuInteractionContext> + { + Callbacks = _ => Task.CompletedTask, + VideoUrl = url + }; + } + + [Fact] + public async Task Enqueue_Then_Dequeue_Returns_Items_In_Fifo_Order() + { + var queue = new MusicQueueService(); + var first = CreateRequest(); + var second = CreateRequest(); + var third = CreateRequest(); + + queue.Enqueue(first); + queue.Enqueue(second); + queue.Enqueue(third); + + Assert.Equal(first.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); + Assert.Equal(second.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); + Assert.Equal(third.Id, (await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None)).Id); + Assert.Equal(0, queue.Count); + } + + [Fact] + public async Task Dequeue_Waits_Until_Item_Is_Enqueued() + { + var queue = new MusicQueueService(); + + var dequeueTask = queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None).AsTask(); + await Task.Delay(100); + Assert.False(dequeueTask.IsCompleted); + + var request = CreateRequest(); + queue.Enqueue(request); + + var dequeued = await dequeueTask.WaitAsync(Timeout); + Assert.Equal(request.Id, dequeued.Id); + } + + [Fact] + public async Task Dequeue_Honors_Cancellation() + { + var queue = new MusicQueueService(); + using var cts = new CancellationTokenSource(); + + var dequeueTask = queue.DequeueAsync<StringMenuInteractionContext>(cts.Token).AsTask(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => dequeueTask.WaitAsync(Timeout)); + } + + [Fact] + public async Task Clear_Removes_Pending_Items_And_Stale_Signals_Do_Not_Yield_Items() + { + var queue = new MusicQueueService(); + queue.Enqueue(CreateRequest()); + queue.Enqueue(CreateRequest()); + + queue.Clear(); + Assert.Equal(0, queue.Count); + + // The two stale signals left behind by Clear must not produce items. + var afterClear = CreateRequest(); + queue.Enqueue(afterClear); + + var dequeued = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(afterClear.Id, dequeued.Id); + + // Nothing is left: the next dequeue must block even though stale signals may remain. + var blocked = queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None).AsTask(); + await Task.Delay(100); + Assert.False(blocked.IsCompleted); + } + + [Fact] + public void Count_Excludes_NowPlaying_And_GetAllRequests_Puts_NowPlaying_First() + { + var queue = new MusicQueueService(); + var playing = CreateRequest(); + var pending = CreateRequest(); + + queue.SetNowPlaying(playing); + queue.Enqueue(pending); + + Assert.Equal(1, queue.Count); + Assert.Equal(playing.Id, queue.NowPlaying?.Id); + + var all = queue.GetAllRequests(); + Assert.Equal(2, all.Length); + Assert.Equal(playing.Id, all[0].Id); + Assert.Equal(pending.Id, all[1].Id); + + queue.SetNowPlaying(null); + Assert.Null(queue.NowPlaying); + Assert.Single(queue.GetAllRequests()); + } + + [Fact] + public async Task Rewind_Inserts_NowPlaying_At_Front_And_Signals() + { + var queue = new MusicQueueService(); + var playing = CreateRequest(); + playing.RetryCount = 2; + var pending = CreateRequest(); + + queue.SetNowPlaying(playing); + queue.Enqueue(pending); + + queue.Rewind(); + + // The rewound track is in front of the previously pending one, with retries reset. + var first = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(playing.Id, first.Id); + Assert.Equal(0, first.RetryCount); + + var second = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(pending.Id, second.Id); + } + + [Fact] + public void Rewind_Without_NowPlaying_Is_A_NoOp() + { + var queue = new MusicQueueService(); + queue.Rewind(); + Assert.Equal(0, queue.Count); + } + + [Fact] + public async Task Concurrent_Enqueues_Are_All_Dequeued_Exactly_Once() + { + var queue = new MusicQueueService(); + const int producers = 4; + const int itemsPerProducer = 25; + const int total = producers * itemsPerProducer; + + var produced = new List<Guid>(); + var producerTasks = Enumerable.Range(0, producers).Select(_ => Task.Run(() => + { + for (var i = 0; i < itemsPerProducer; i++) + { + var request = CreateRequest(); + lock (produced) + { + produced.Add(request.Id); + } + + queue.Enqueue(request); + } + })).ToArray(); + + var consumed = new List<Guid>(); + var consumerTask = Task.Run(async () => + { + for (var i = 0; i < total; i++) + { + var item = await queue.DequeueAsync<StringMenuInteractionContext>(CancellationToken.None); + consumed.Add(item.Id); + } + }); + + await Task.WhenAll(producerTasks).WaitAsync(Timeout); + await consumerTask.WaitAsync(Timeout); + + Assert.Equal(total, consumed.Count); + Assert.Equal(consumed.Count, consumed.Distinct().Count()); + Assert.Equal(produced.OrderBy(id => id), consumed.OrderBy(id => id)); + Assert.Equal(0, queue.Count); + } +} diff --git a/src/UI/Api/Api.csproj b/src/UI/Api/Api.csproj index 5d4d64b..528aec8 100644 --- a/src/UI/Api/Api.csproj +++ b/src/UI/Api/Api.csproj @@ -1,13 +1,13 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> - <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> - <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\Application\Application.csproj" /> - <ProjectReference Include="..\..\Infrastructure\Infrastructure.csproj" /> - </ItemGroup> -</Project> +<Project Sdk="Microsoft.NET.Sdk"> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> + <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Application\Application.csproj" /> + <ProjectReference Include="..\..\Infrastructure\Infrastructure.csproj" /> + </ItemGroup> +</Project> diff --git a/src/UI/Api/ControllerExtensions.cs b/src/UI/Api/ControllerExtensions.cs index 0451fac..756feb1 100644 --- a/src/UI/Api/ControllerExtensions.cs +++ b/src/UI/Api/ControllerExtensions.cs @@ -1,162 +1,162 @@ -using Application.Interfaces.Services; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; - -namespace Api; - -public static class ControllerExtensions -{ - extension(WebApplication app) - { - public void AddApiController() - { - app.MapGet("/api/statistics-all", - async (IStatisticsService statisticsService) => - await statisticsService.GetAllSongsAsync()) - .WithName("GetStatisticsAll"); - - app.MapGet("/api/statistics-today", - async (IStatisticsService statisticsService, int? limit) => - await statisticsService.GetTopSongsAsync(true, limit ?? 100)) - .WithName("GetStatisticsToday"); - - app.MapGet("/api/users", - async (IUserService userService) => await userService.GetAllUsersAsync()) - .WithName("GetAllUsers"); - - app.MapGet("/api/radio-sources", - async (IRadioSourceService radioSourceService, CancellationToken cancellationToken) => - await radioSourceService.GetAllRadioSourcesAsync(cancellationToken)) - .RequireAuthorization() - .WithName("GetAllRadioSources"); - - app.MapPut("/api/radio-sources/{id:guid}", - async (IRadioSourceService radioSourceService, Guid id, [FromBody] UpdateRadioSourceRequest request, - CancellationToken cancellationToken) => - { - try - { - await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.NewSourceUrl, - request.IsActive, cancellationToken); - return Results.NoContent(); - } - catch (KeyNotFoundException) - { - return Results.NotFound(); - } - catch (ArgumentException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - }) - .RequireAuthorization() - .WithName("UpdateRadioSourceUrl"); - - app.MapGet("/api/radio-sources/{id:guid}", - async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) => - { - try - { - var radioSource = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken); - return Results.Ok(radioSource); - } - catch (KeyNotFoundException) - { - return Results.NotFound(); - } - }) - .RequireAuthorization() - .WithName("GetRadioSourceById"); - - app.MapPost("/api/radio-sources/add", - async (IRadioSourceService radioSourceService, [FromBody] AddRadioSourceRequest request, - CancellationToken cancellationToken) => - { - try - { - var id = await radioSourceService.AddRadioSourceAsync(request.Name, request.SourceUrl, - cancellationToken); - var result = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken); - return Results.Created($"/api/radio-sources/{id}", result); - } - catch (InvalidOperationException ex) - { - return Results.BadRequest(new { error = ex.Message }); - } - catch (Exception) - { - return Results.Problem("An unexpected error occurred."); - } - }) - .RequireAuthorization() - .WithName("AddRadioSource"); - - - app.MapDelete("/api/radio-sources/{id:guid}", - async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) => - { - try - { - await radioSourceService.DeleteRadioSourceAsync(id, cancellationToken); - return Results.NoContent(); - } - catch (KeyNotFoundException) - { - return Results.NotFound(); - } - }) - .RequireAuthorization() - .WithName("DeleteRadioSource"); - - app.MapPost("/api/login", - async (IUserService userService, IConfiguration configuration, IJwtTokenGenerator tokenGenerator, - [FromBody] LoginRequest request) => - { - try - { - var user = await userService.GetUserByUsernameAsync(request.Username); - // Note: In a real application, you would hash the password and compare it securely. - var password = configuration.GetValue<string>("JwtSettings:InternalPassword"); - // Fail closed if the internal password is not configured or the request omits one. - if (string.IsNullOrEmpty(password) || user == null || password != request.Password) - { - throw new UnauthorizedAccessException("Invalid username or password."); - } - - var token = tokenGenerator.GenerateToken(request); - return Results.Ok(new { token }); - } - catch (UnauthorizedAccessException) - { - return Results.Unauthorized(); - } - catch (Exception) - { - return Results.Problem("An unexpected error occurred."); - } - }) - .AllowAnonymous() - .WithName("Login"); - - app.MapGet("/api/auth/validate-token", (HttpContext context) => - { - // Check if user is authenticated (JWT middleware already validated the token) - if (context.User.Identity?.IsAuthenticated == true) - { - return Results.Ok(new - { - valid = true, - username = context.User.Identity.Name, - expires = context.User.FindFirst("exp")?.Value - }); - } - - return Results.Unauthorized(); - }) - .RequireAuthorization() - .WithName("ValidateToken"); - } - } +using Application.Interfaces.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; + +namespace Api; + +public static class ControllerExtensions +{ + extension(WebApplication app) + { + public void AddApiController() + { + app.MapGet("/api/statistics-all", + async (IStatisticsService statisticsService) => + await statisticsService.GetAllSongsAsync()) + .WithName("GetStatisticsAll"); + + app.MapGet("/api/statistics-today", + async (IStatisticsService statisticsService, int? limit) => + await statisticsService.GetTopSongsAsync(true, limit ?? 100)) + .WithName("GetStatisticsToday"); + + app.MapGet("/api/users", + async (IUserService userService) => await userService.GetAllUsersAsync()) + .WithName("GetAllUsers"); + + app.MapGet("/api/radio-sources", + async (IRadioSourceService radioSourceService, CancellationToken cancellationToken) => + await radioSourceService.GetAllRadioSourcesAsync(cancellationToken)) + .RequireAuthorization() + .WithName("GetAllRadioSources"); + + app.MapPut("/api/radio-sources/{id:guid}", + async (IRadioSourceService radioSourceService, Guid id, [FromBody] UpdateRadioSourceRequest request, + CancellationToken cancellationToken) => + { + try + { + await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.NewSourceUrl, + request.IsActive, cancellationToken); + return Results.NoContent(); + } + catch (KeyNotFoundException) + { + return Results.NotFound(); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }) + .RequireAuthorization() + .WithName("UpdateRadioSourceUrl"); + + app.MapGet("/api/radio-sources/{id:guid}", + async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) => + { + try + { + var radioSource = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken); + return Results.Ok(radioSource); + } + catch (KeyNotFoundException) + { + return Results.NotFound(); + } + }) + .RequireAuthorization() + .WithName("GetRadioSourceById"); + + app.MapPost("/api/radio-sources/add", + async (IRadioSourceService radioSourceService, [FromBody] AddRadioSourceRequest request, + CancellationToken cancellationToken) => + { + try + { + var id = await radioSourceService.AddRadioSourceAsync(request.Name, request.SourceUrl, + cancellationToken); + var result = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken); + return Results.Created($"/api/radio-sources/{id}", result); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + catch (Exception) + { + return Results.Problem("An unexpected error occurred."); + } + }) + .RequireAuthorization() + .WithName("AddRadioSource"); + + + app.MapDelete("/api/radio-sources/{id:guid}", + async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) => + { + try + { + await radioSourceService.DeleteRadioSourceAsync(id, cancellationToken); + return Results.NoContent(); + } + catch (KeyNotFoundException) + { + return Results.NotFound(); + } + }) + .RequireAuthorization() + .WithName("DeleteRadioSource"); + + app.MapPost("/api/login", + async (IUserService userService, IConfiguration configuration, IJwtTokenGenerator tokenGenerator, + [FromBody] LoginRequest request) => + { + try + { + var user = await userService.GetUserByUsernameAsync(request.Username); + // Note: In a real application, you would hash the password and compare it securely. + var password = configuration.GetValue<string>("JwtSettings:InternalPassword"); + // Fail closed if the internal password is not configured or the request omits one. + if (string.IsNullOrEmpty(password) || user == null || password != request.Password) + { + throw new UnauthorizedAccessException("Invalid username or password."); + } + + var token = tokenGenerator.GenerateToken(request); + return Results.Ok(new { token }); + } + catch (UnauthorizedAccessException) + { + return Results.Unauthorized(); + } + catch (Exception) + { + return Results.Problem("An unexpected error occurred."); + } + }) + .AllowAnonymous() + .WithName("Login"); + + app.MapGet("/api/auth/validate-token", (HttpContext context) => + { + // Check if user is authenticated (JWT middleware already validated the token) + if (context.User.Identity?.IsAuthenticated == true) + { + return Results.Ok(new + { + valid = true, + username = context.User.Identity.Name, + expires = context.User.FindFirst("exp")?.Value + }); + } + + return Results.Unauthorized(); + }) + .RequireAuthorization() + .WithName("ValidateToken"); + } + } } \ No newline at end of file diff --git a/src/UI/Api/DependencyResolver.cs b/src/UI/Api/DependencyResolver.cs index e9a69b2..fd8ed6c 100644 --- a/src/UI/Api/DependencyResolver.cs +++ b/src/UI/Api/DependencyResolver.cs @@ -1,46 +1,46 @@ -using System.Text; -using Infrastructure.Data; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; - -namespace Api; - -public static class DependencyResolver -{ - public static void AddApiDependencies(this IServiceCollection services, IConfiguration configuration) - { - services.AddScoped<IJwtTokenGenerator, JwtTokenGenerator>(); - - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = false, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(configuration.GetValue<string>("JwtSettings:Secret") ?? - throw new InvalidOperationException( - "JwtSettings:Secret is not configured"))), - ValidIssuer = configuration.GetValue<string>("JwtSettings:Issuer") ?? - throw new InvalidOperationException("JwtSettings:Issuer is not configured"), - // The token carries the raw "name" claim; without this, Identity.Name is null. - NameClaimType = "name", - }; - - options.Events = new JwtBearerEvents - { - OnAuthenticationFailed = context => - { - Console.WriteLine($"Authentication failed: {context.Exception.Message}"); - return Task.CompletedTask; - } - }; - }); - } +using System.Text; +using Infrastructure.Data; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace Api; + +public static class DependencyResolver +{ + public static void AddApiDependencies(this IServiceCollection services, IConfiguration configuration) + { + services.AddScoped<IJwtTokenGenerator, JwtTokenGenerator>(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(configuration.GetValue<string>("JwtSettings:Secret") ?? + throw new InvalidOperationException( + "JwtSettings:Secret is not configured"))), + ValidIssuer = configuration.GetValue<string>("JwtSettings:Issuer") ?? + throw new InvalidOperationException("JwtSettings:Issuer is not configured"), + // The token carries the raw "name" claim; without this, Identity.Name is null. + NameClaimType = "name", + }; + + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + Console.WriteLine($"Authentication failed: {context.Exception.Message}"); + return Task.CompletedTask; + } + }; + }); + } } \ No newline at end of file diff --git a/src/UI/Api/JwtTokenGenerator.cs b/src/UI/Api/JwtTokenGenerator.cs index 8d37a08..be939b3 100644 --- a/src/UI/Api/JwtTokenGenerator.cs +++ b/src/UI/Api/JwtTokenGenerator.cs @@ -1,38 +1,38 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Microsoft.Extensions.Configuration; -using Microsoft.IdentityModel.Tokens; -using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; - -namespace Api; - -public interface IJwtTokenGenerator -{ - string GenerateToken(LoginRequest request); -} - -public class JwtTokenGenerator(IConfiguration configuration): IJwtTokenGenerator -{ - public string GenerateToken(LoginRequest request) - { - var signingCredentials = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JwtSettings:Secret") ?? string.Empty)), - SecurityAlgorithms.HmacSha256); - - var claims = new[] - { - new Claim(JwtRegisteredClaimNames.Name, request.Username), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - - var token = new JwtSecurityToken( - issuer: configuration.GetValue<string>("JwtSettings:Issuer"), - audience: configuration.GetValue<string>("JwtSettings:Audience"), - claims: claims, - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: signingCredentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; + +namespace Api; + +public interface IJwtTokenGenerator +{ + string GenerateToken(LoginRequest request); +} + +public class JwtTokenGenerator(IConfiguration configuration): IJwtTokenGenerator +{ + public string GenerateToken(LoginRequest request) + { + var signingCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JwtSettings:Secret") ?? string.Empty)), + SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Name, request.Username), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: configuration.GetValue<string>("JwtSettings:Issuer"), + audience: configuration.GetValue<string>("JwtSettings:Audience"), + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: signingCredentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } } \ No newline at end of file diff --git a/src/UI/Api/Requests.cs b/src/UI/Api/Requests.cs index 801ec2a..d0fbe33 100644 --- a/src/UI/Api/Requests.cs +++ b/src/UI/Api/Requests.cs @@ -1,6 +1,6 @@ -namespace Api; - -public record UpdateRadioSourceRequest(string Name, string NewSourceUrl, bool IsActive); -public record AddRadioSourceRequest(string Name, string SourceUrl); - +namespace Api; + +public record UpdateRadioSourceRequest(string Name, string NewSourceUrl, bool IsActive); +public record AddRadioSourceRequest(string Name, string SourceUrl); + public record LoginRequest(string Username, string Password); \ No newline at end of file diff --git a/src/UI/Api/appsettings.Development.json b/src/UI/Api/appsettings.Development.json index 0c208ae..ff66ba6 100644 --- a/src/UI/Api/appsettings.Development.json +++ b/src/UI/Api/appsettings.Development.json @@ -1,8 +1,8 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/UI/Api/appsettings.json b/src/UI/Api/appsettings.json index 10f68b8..4d56694 100644 --- a/src/UI/Api/appsettings.json +++ b/src/UI/Api/appsettings.json @@ -1,9 +1,9 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/UI/App/.prettierrc b/src/UI/App/.prettierrc index 9e1dc24..b6a4831 100644 --- a/src/UI/App/.prettierrc +++ b/src/UI/App/.prettierrc @@ -1,12 +1,12 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "bracketSpacing": true, - "jsxSingleQuote": false, - "arrowParens": "always", - "endOfLine": "auto" -} +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "jsxSingleQuote": false, + "arrowParens": "always", + "endOfLine": "auto" +} diff --git a/src/UI/App/App.esproj b/src/UI/App/App.esproj index a70d575..f5ff918 100644 --- a/src/UI/App/App.esproj +++ b/src/UI/App/App.esproj @@ -1,8 +1,8 @@ -<Project Sdk="Microsoft.VisualStudio.JavaScript.SDK/1.0.5171056"> - - <PropertyGroup> - <ProjectType>web</ProjectType> - <JavaScriptRoot>./</JavaScriptRoot> - </PropertyGroup> - -</Project> +<Project Sdk="Microsoft.VisualStudio.JavaScript.SDK/1.0.5171056"> + + <PropertyGroup> + <ProjectType>web</ProjectType> + <JavaScriptRoot>./</JavaScriptRoot> + </PropertyGroup> + +</Project> diff --git a/src/UI/App/README.md b/src/UI/App/README.md index 66770df..da27509 100644 --- a/src/UI/App/README.md +++ b/src/UI/App/README.md @@ -1,75 +1,75 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. - -Note: This will impact Vite dev & build performances. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x'; -import reactDom from 'eslint-plugin-react-dom'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. + +Note: This will impact Vite dev & build performances. + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` diff --git a/src/UI/App/bun.lock b/src/UI/App/bun.lock index b0db138..1ca56d8 100644 --- a/src/UI/App/bun.lock +++ b/src/UI/App/bun.lock @@ -14,11 +14,11 @@ "sonner": "^2.0.7", }, "devDependencies": { - "@babel/core": "^7.29.0", + "@babel/core": "^8.0.1", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.1", "@types/babel__core": "^7.20.5", - "@types/node": "^25.5.2", + "@types/node": "^26.2.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -30,44 +30,44 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "prettier": "^3.8.1", - "typescript": "~6.0.2", + "typescript": "~7.0.2", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1", + "vite": "^8.2.1", }, }, }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@8.0.0", "", {}, "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@8.0.1", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-compilation-targets": "^8.0.0", "@babel/helpers": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", "semver": "^7.7.3" } }, "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@8.0.0", "", { "dependencies": { "@babel/compat-data": "^8.0.0", "@babel/helper-validator-option": "^8.0.0", "browserslist": "^4.24.0", "lru-cache": "^11.0.0", "semver": "^7.7.3" } }, "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@8.0.0", "", {}, "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@8.0.0", "", { "dependencies": { "@babel/template": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -201,9 +201,13 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="], + + "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -231,6 +235,46 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -293,6 +337,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -359,6 +405,8 @@ "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -371,7 +419,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -387,44 +435,46 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -439,7 +489,7 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -469,7 +519,7 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], @@ -487,7 +537,7 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -495,11 +545,11 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "typescript-eslint": ["typescript-eslint@8.58.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -509,7 +559,7 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], + "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -523,14 +573,170 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "babel-plugin-react-compiler/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + + "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "babel-plugin-react-compiler/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "eslint-plugin-react-hooks/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], + + "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], } } diff --git a/src/UI/App/eslint.config.js b/src/UI/App/eslint.config.js index b83d4e1..5de8cf5 100644 --- a/src/UI/App/eslint.config.js +++ b/src/UI/App/eslint.config.js @@ -1,28 +1,28 @@ -import js from '@eslint/js'; -import globals from 'globals'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import tseslint from 'typescript-eslint'; -import { defineConfig, globalIgnores } from 'eslint/config'; -import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - plugins: { - 'react-hooks': reactHooks, - }, - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - }, - eslintPluginPrettier, -]); +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + plugins: { + 'react-hooks': reactHooks, + }, + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, + eslintPluginPrettier, +]); diff --git a/src/UI/App/index.html b/src/UI/App/index.html index aa8d2e6..dad6fd0 100644 --- a/src/UI/App/index.html +++ b/src/UI/App/index.html @@ -1,13 +1,13 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/logo.png" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Discord Bot Dashboard - - -
- - - + + + + + + + Discord Bot Dashboard + + +
+ + + diff --git a/src/UI/App/package-lock.json b/src/UI/App/package-lock.json deleted file mode 100644 index e3c1470..0000000 --- a/src/UI/App/package-lock.json +++ /dev/null @@ -1,3124 +0,0 @@ -{ - "name": "app-v2", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "app-v2", - "version": "0.0.0", - "dependencies": { - "@tanstack/react-query": "^5.96.2", - "@tanstack/react-router": "^1.168.10", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-is": "^19.2.4", - "recharts": "^3.8.1" - }, - "devDependencies": { - "@babel/core": "^7.29.0", - "@eslint/js": "^10.0.1", - "@rolldown/plugin-babel": "^0.2.1", - "@types/babel__core": "^7.20.5", - "@types/node": "^25.5.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "babel-plugin-react-compiler": "^1.0.0", - "eslint": "^10.2.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "prettier": "^3.8.1", - "typescript": "~6.0.2", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.4", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.4", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.4", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.4", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.122.0", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/plugin-babel": { - "version": "0.2.2", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=22.12.0 || ^24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.29.0 || ^8.0.0-rc.1", - "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", - "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", - "rolldown": "^1.0.0-rc.5", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@babel/plugin-transform-runtime": { - "optional": true - }, - "@babel/runtime": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@tanstack/history": { - "version": "1.161.6", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", - "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.96.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", - "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.96.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", - "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.96.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tanstack/react-router": { - "version": "1.168.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.10.tgz", - "integrity": "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.161.6", - "@tanstack/react-store": "^0.9.3", - "@tanstack/router-core": "1.168.9", - "isbot": "^5.1.22" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", - "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "0.9.3", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/router-core": { - "version": "1.168.9", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.9.tgz", - "integrity": "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.161.6", - "cookie-es": "^2.0.0", - "seroval": "^1.4.2", - "seroval-plugins": "^1.4.2" - }, - "bin": { - "intent": "bin/intent.js" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tybys/wasm-util": { - "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" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.2", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", - "debug": "^4.4.3" - }, - "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.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "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.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.15", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001785", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie-es": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", - "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "dev": true, - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "dev": true, - "license": "ISC" - }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/escalade": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.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", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "17.4.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isbot": { - "version": "5.1.37", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.37.tgz", - "integrity": "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==", - "license": "Unlicense", - "engines": { - "node": ">=18" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "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.32.0", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "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", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.37", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.1", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.4", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/recharts": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", - "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/seroval": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", - "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz", - "integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/synckit": { - "version": "0.11.12", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "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==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "6.0.2", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.58.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/vite": { - "version": "8.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/src/UI/App/package.json b/src/UI/App/package.json index 4eb1956..b8e0661 100644 --- a/src/UI/App/package.json +++ b/src/UI/App/package.json @@ -1,45 +1,45 @@ -{ - "name": "app-v2", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "build:dev": "tsc -b && vite build --mode development", - "lint": "eslint .", - "preview": "vite preview", - "format": "prettier --write .", - "format:check": "prettier --check ." - }, - "dependencies": { - "@tanstack/react-query": "^5.96.2", - "@tanstack/react-router": "^1.168.10", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-is": "^19.2.4", - "recharts": "^3.8.1", - "sonner": "^2.0.7" - }, - "devDependencies": { - "@babel/core": "^7.29.0", - "@eslint/js": "^10.0.1", - "@rolldown/plugin-babel": "^0.2.1", - "@types/babel__core": "^7.20.5", - "@types/node": "^25.5.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "babel-plugin-react-compiler": "^1.0.0", - "eslint": "^10.2.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "prettier": "^3.8.1", - "typescript": "~6.0.2", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" - } -} +{ + "name": "app-v2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:dev": "tsc -b && vite build --mode development", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@tanstack/react-query": "^5.96.2", + "@tanstack/react-router": "^1.168.10", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-is": "^19.2.4", + "recharts": "^3.8.1", + "sonner": "^2.0.7" + }, + "devDependencies": { + "@babel/core": "^8.0.1", + "@eslint/js": "^10.0.1", + "@rolldown/plugin-babel": "^0.2.1", + "@types/babel__core": "^7.20.5", + "@types/node": "^26.2.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "typescript": "~7.0.2", + "typescript-eslint": "^8.57.0", + "vite": "^8.2.1" + } +} \ No newline at end of file diff --git a/src/UI/App/public/icons.svg b/src/UI/App/public/icons.svg index e952219..5615851 100644 --- a/src/UI/App/public/icons.svg +++ b/src/UI/App/public/icons.svg @@ -1,24 +1,24 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UI/App/src/App.tsx b/src/UI/App/src/App.tsx index 56a547f..2a108ee 100644 --- a/src/UI/App/src/App.tsx +++ b/src/UI/App/src/App.tsx @@ -1,20 +1,20 @@ -import { RouterProvider } from '@tanstack/react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Toaster } from 'sonner'; -import { AuthProvider } from './context/AuthContext'; -import { router } from './router'; - -const queryClient = new QueryClient(); - -function App() { - return ( - - - - - - - ); -} - -export default App; +import { RouterProvider } from '@tanstack/react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'sonner'; +import { AuthProvider } from './context/AuthContext'; +import { router } from './router'; + +const queryClient = new QueryClient(); + +function App() { + return ( + + + + + + + ); +} + +export default App; diff --git a/src/UI/App/src/components/AppError.tsx b/src/UI/App/src/components/AppError.tsx index b25e5e0..d320220 100644 --- a/src/UI/App/src/components/AppError.tsx +++ b/src/UI/App/src/components/AppError.tsx @@ -1,14 +1,14 @@ -interface AppErrorProps { - message?: string; -} - -export function AppError({ - message = 'An unexpected error occurred.', -}: AppErrorProps) { - return ( -
-

Error

-

{message}

-
- ); -} +interface AppErrorProps { + message?: string; +} + +export function AppError({ + message = 'An unexpected error occurred.', +}: AppErrorProps) { + return ( +
+

Error

+

{message}

+
+ ); +} diff --git a/src/UI/App/src/components/LoadingSpinner.tsx b/src/UI/App/src/components/LoadingSpinner.tsx index a9cc69b..3729506 100644 --- a/src/UI/App/src/components/LoadingSpinner.tsx +++ b/src/UI/App/src/components/LoadingSpinner.tsx @@ -1,7 +1,7 @@ -export function LoadingSpinner() { - return ( -
- -
- ); -} +export function LoadingSpinner() { + return ( +
+ +
+ ); +} diff --git a/src/UI/App/src/context/AuthContext.tsx b/src/UI/App/src/context/AuthContext.tsx index 7dd19c2..812297a 100644 --- a/src/UI/App/src/context/AuthContext.tsx +++ b/src/UI/App/src/context/AuthContext.tsx @@ -1,53 +1,53 @@ -import { useState, useEffect, useCallback, type ReactNode } from 'react'; -import { AuthContext } from '../hooks/useAuth'; -import { API_BASE_URL } from '../services/api'; - -export function AuthProvider({ children }: { children: ReactNode }) { - const [isAuthenticated, setIsAuthenticated] = useState(false); - - const validateToken = useCallback(async () => { - const token = localStorage.getItem('authToken'); - if (!token) { - setIsAuthenticated(false); - return false; - } - try { - const response = await fetch(`${API_BASE_URL}/auth/validate-token`, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - if (!response.ok) { - // Token is expired or invalid; drop it so route guards fail closed too. - localStorage.removeItem('authToken'); - } - setIsAuthenticated(response.ok); - return response.ok; - } catch { - setIsAuthenticated(false); - return false; - } - }, []); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - validateToken(); - }, [validateToken]); - - const login = useCallback((token: string) => { - localStorage.setItem('authToken', token); - setIsAuthenticated(true); - }, []); - - const logout = useCallback(() => { - localStorage.removeItem('authToken'); - setIsAuthenticated(false); - }, []); - - return ( - - {children} - - ); -} +import { useState, useEffect, useCallback, type ReactNode } from 'react'; +import { AuthContext } from '../hooks/useAuth'; +import { API_BASE_URL } from '../services/api'; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + + const validateToken = useCallback(async () => { + const token = localStorage.getItem('authToken'); + if (!token) { + setIsAuthenticated(false); + return false; + } + try { + const response = await fetch(`${API_BASE_URL}/auth/validate-token`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + // Token is expired or invalid; drop it so route guards fail closed too. + localStorage.removeItem('authToken'); + } + setIsAuthenticated(response.ok); + return response.ok; + } catch { + setIsAuthenticated(false); + return false; + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + validateToken(); + }, [validateToken]); + + const login = useCallback((token: string) => { + localStorage.setItem('authToken', token); + setIsAuthenticated(true); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem('authToken'); + setIsAuthenticated(false); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/UI/App/src/hooks/useAuth.tsx b/src/UI/App/src/hooks/useAuth.tsx index 87a783e..b0b5a47 100644 --- a/src/UI/App/src/hooks/useAuth.tsx +++ b/src/UI/App/src/hooks/useAuth.tsx @@ -1,17 +1,17 @@ -import { createContext, useContext } from 'react'; - -export interface AuthContextType { - isAuthenticated: boolean; - login: (token: string) => void; - logout: () => void; -} - -export const AuthContext = createContext(null); - -export function useAuth(): AuthContextType { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -} +import { createContext, useContext } from 'react'; + +export interface AuthContextType { + isAuthenticated: boolean; + login: (token: string) => void; + logout: () => void; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/src/UI/App/src/index.css b/src/UI/App/src/index.css index 2a3b435..496b9e8 100644 --- a/src/UI/App/src/index.css +++ b/src/UI/App/src/index.css @@ -1,1250 +1,1250 @@ -@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap'); - -:root { - font-family: 'Quicksand', sans-serif; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 0; - background: linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%); -} - -#root { - display: block; - min-height: 100vh; -} - -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.1); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -::placeholder { - color: rgba(255, 255, 255, 0.5); -} - -/* ===== Common Styles ===== */ - -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.title { - color: rgba(255, 255, 255, 0.9); - font-size: 2rem; - font-weight: 700; - margin: 0; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.glass-card { - background: rgba(0, 0, 0, 0.1); - backdrop-filter: blur(40px) saturate(180%); - -webkit-backdrop-filter: blur(40px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 1rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - transition: all 0.3s ease; -} - -.glass-card:hover { - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); - background: rgba(0, 0, 0, 0.15); - border-color: rgba(255, 255, 255, 0.3); -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - padding: 1.5rem; -} - -.stat-card:hover { - transform: translateY(-5px); - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.3); -} - -.stat-value, -.stat-song-title { - color: rgba(255, 255, 255, 1); - margin: 0; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.stat-value { - font-size: 2rem; - font-weight: 700; - overflow-wrap: anywhere; -} - -.stat-song-title { - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: anywhere; - font-size: 1.2rem; -} - -.stat-label { - color: rgba(255, 255, 255, 0.7); - font-size: 0.9rem; - margin: 0.5rem 0 0 0; -} - -.content-card { - padding: 2rem; - min-height: 400px; - overflow: auto; -} - -.glass-button { - background: rgba(0, 0, 0, 0.2); - backdrop-filter: blur(20px); - color: rgba(255, 255, 255, 0.9); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 50px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.glass-button:hover { - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); - background: rgba(0, 0, 0, 0.25); - border-color: rgba(0, 0, 0, 0.4); -} - -.view-toggle { - display: flex; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 50px; - padding: 0.25rem; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.toggle-button { - background: none; - border: none; - color: rgba(255, 255, 255, 0.7); - padding: 0.5rem 1rem; - border-radius: 50px; - cursor: pointer; - font-size: 0.9rem; - transition: all 0.3s ease; -} - -.toggle-button.active { - background: rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 1); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); - backdrop-filter: blur(20px); -} - -.table-wrap { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.table { - width: 100%; - border-collapse: collapse; -} - -.table th.sortable { - cursor: pointer; - user-select: none; -} - -.table th.sortable:hover { - color: rgba(255, 255, 255, 1); -} - -.sort-header { - display: inline-flex; - align-items: center; - gap: 0.35rem; -} - -.sort-arrow { - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 5px solid rgba(255, 255, 255, 0.25); -} - -.sort-arrow.active { - border-top-color: rgba(255, 255, 255, 0.9); -} - -.sort-arrow.active.asc { - border-top: none; - border-bottom: 5px solid rgba(255, 255, 255, 0.9); -} - -.empty-state { - text-align: center; - color: rgba(255, 255, 255, 0.6); - padding: 2rem 1rem; -} - -.table th, -.table td { - text-align: left; - padding: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.table th { - color: rgba(255, 255, 255, 0.9); - font-weight: 600; - font-size: 0.9rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.table td { - color: rgba(255, 255, 255, 0.8); -} - -.table td:first-child { - width: 10%; -} - -.table tr { - transition: background 0.5s ease; -} - -.table tr:hover { - background: rgba(255, 255, 255, 0.05); -} - -.play-count { - background: rgba(255, 255, 255, 0.2); - backdrop-filter: blur(20px); - color: rgba(255, 255, 255, 0.9); - border: 1px solid rgba(255, 255, 255, 0.3); - padding: 0.25rem 0.75rem; - border-radius: 50px; - font-size: 0.8rem; - font-weight: 600; - display: inline-block; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.chart-container { - position: relative; - height: 400px; - width: 100%; -} - -/* ===== Toolbar (search / filters) ===== */ - -.toolbar { - display: flex; - align-items: center; - gap: 0.75rem; - flex-wrap: wrap; - margin-bottom: 1.5rem; -} - -.search-input { - flex: 1; - min-width: 180px; - max-width: 340px; - border-radius: 50px; - padding: 0.6rem 1.1rem; -} - -/* ===== Overview page ===== */ - -.overview-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.5rem; -} - -.panel { - padding: 1.5rem; -} - -.panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.panel-title { - font-size: 1.05rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.9); - margin: 0; -} - -.view-all-button { - padding: 0.35rem 0.9rem; - font-size: 0.75rem; - white-space: nowrap; -} - -.panel-empty { - color: rgba(255, 255, 255, 0.55); - font-size: 0.9rem; - padding: 0.5rem 0; - margin: 0; -} - -.rank-list { - list-style: none; - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0; - padding: 0; -} - -.rank-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.55rem 0.7rem; - border-radius: 0.6rem; - background: rgba(255, 255, 255, 0.05); -} - -.rank-badge { - width: 1.7rem; - height: 1.7rem; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.8rem; - font-weight: 700; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.8); -} - -.rank-badge.rank-1 { - background: #f5c542; - color: #3b2f00; -} - -.rank-badge.rank-2 { - background: #c8d1dc; - color: #2b3542; -} - -.rank-badge.rank-3 { - background: #d2996b; - color: #3d2412; -} - -.rank-info { - flex: 1; - min-width: 0; -} - -.rank-label { - font-size: 0.9rem; - color: rgba(255, 255, 255, 0.9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.rank-sublabel { - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.55); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.rank-value { - font-size: 0.85rem; - font-weight: 600; - color: #86b6ef; - white-space: nowrap; - flex-shrink: 0; -} - -/* ===== Pagination ===== */ - -.pagination { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - flex-wrap: wrap; - margin-top: 1.5rem; -} - -.pagination-info { - color: rgba(255, 255, 255, 0.6); - font-size: 0.85rem; -} - -.pagination-controls { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.pagination-button { - padding: 0.5rem 1.1rem; - font-size: 0.85rem; -} - -.pagination-button:disabled { - opacity: 0.4; - cursor: not-allowed; - transform: none; -} - -.pagination-page { - color: rgba(255, 255, 255, 0.8); - font-size: 0.85rem; - font-variant-numeric: tabular-nums; -} - -.loading { - display: flex; - justify-content: center; - align-items: center; - height: 50vh; - color: rgba(255, 255, 255, 0.7); - font-size: 1.1rem; -} - -.form-group { - margin-bottom: 1.5rem; -} - -.form-label { - display: block; - color: rgba(255, 255, 255, 0.9); - font-size: 0.9rem; - font-weight: 500; - margin-bottom: 0.5rem; -} - -.form-input, -.form-textarea, -.form-select { - width: 100%; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.9); - padding: 0.75rem; - border-radius: 0.5rem; - font-size: 0.9rem; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - overflow-y: auto; -} - -.form-input:focus, -.form-textarea:focus, -.form-select:focus { - outline: none; - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.15); -} - -.form-textarea { - resize: vertical; - min-height: 80px; -} - -.modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(10px); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.modal-content { - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(40px) saturate(180%); - -webkit-backdrop-filter: blur(40px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 1rem; - padding: 2rem; - max-width: 500px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); -} - -.modal-title { - color: rgba(255, 255, 255, 0.9); - font-size: 1.5rem; - font-weight: 600; - margin: 0; -} - -.close-button { - background: none; - border: none; - color: rgba(255, 255, 255, 0.7); - font-size: 1.5rem; - cursor: pointer; - transition: all 0.3s ease; -} - -.close-button:hover { - color: rgba(255, 255, 255, 0.9); -} - -.form-checkbox { - display: flex; - align-items: center; - gap: 0.5rem; - color: rgba(255, 255, 255, 0.8); -} - -.form-checkbox input { - width: auto; -} - -.form-actions { - display: flex; - gap: 1rem; - justify-content: flex-end; - margin-top: 2rem; -} - -.form-button { - padding: 0.75rem 1.5rem; - font-size: 0.9rem; -} - -.form-button.secondary { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.8); - border-color: rgba(255, 255, 255, 0.2); -} - -/* ===== Dashboard Layout ===== */ - -.header-container { - background: rgba(0, 0, 0, 0.1); - backdrop-filter: blur(40px) saturate(180%); - -webkit-backdrop-filter: blur(40px) saturate(180%); - border: 1px solid rgba(0, 0, 0, 0.2); - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - padding: 1rem 2rem; - position: sticky; - top: 0; - z-index: 100; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); -} - -.header-content { - max-width: 1200px; - margin: 0 auto; - display: flex; - align-items: center; - gap: 1rem; -} - -.logo { - display: flex; - align-items: center; - gap: 0.75rem; - color: rgba(255, 255, 255, 0.9); - font-size: 1.5rem; - font-weight: 700; -} - -.logo-icon { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.2rem; - color: rgba(255, 255, 255, 0.9); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.nav { - display: flex; - gap: 0.5rem; - align-items: center; - margin-left: auto; -} - -.header-actions { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.nav-button { - padding: 0.75rem 1.5rem; - font-size: 0.9rem; - font-weight: 500; - position: relative; - overflow: hidden; -} - -.nav-button:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.9); - transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.nav-button.active { - color: rgba(255, 255, 255, 1); - background: rgba(255, 255, 255, 0.07); - border-color: rgba(255, 255, 255, 0.3); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); -} - -.nav-button.active::before { - content: ''; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 50%; - height: 2px; - background: rgba(255, 255, 255, 0.8); - border-radius: 1px; -} - -.main { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; -} - -.tab-content { - opacity: 0; - animation: fadeIn 0.5s ease forwards; -} - -.login-icon { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - width: 20px; -} - -@keyframes fadeIn { - to { - opacity: 1; - } -} - -/* ===== Spinner ===== */ - -.spinner { - width: 100px; - height: 100px; - border-radius: 50%; - display: inline-block; - border-top: 3px solid #fff; - border-right: 3px solid transparent; - box-sizing: border-box; - animation: rotation 1s linear infinite; -} - -@keyframes rotation { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* ===== Error ===== */ - -.error { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #fb8997; - font-family: Arial, sans-serif; -} - -/* ===== Song Stats ===== */ - -.song-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.song-title { - font-weight: 600; - color: rgba(255, 255, 255, 0.9); -} - -.song-artist { - font-size: 0.9rem; - color: rgba(255, 255, 255, 0.6); -} - -/* ===== User Stats ===== */ - -.user-info { - display: flex; - align-items: center; - gap: 1rem; -} - -.user-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.2); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.3); - display: flex; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.9); - font-weight: 600; - font-size: 1.2rem; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.user-details { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.username { - font-weight: 600; - color: rgba(255, 255, 255, 0.9); -} - -.discriminator { - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.5); -} - -.unique-song { - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(20px); - color: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 0.25rem 0.75rem; - border-radius: 50px; - font-size: 0.8rem; - display: inline-block; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -/* ===== Recently Played (expanded user row) ===== */ - -.table tr.recent-songs-row:hover { - background: transparent; -} - -.recent-songs { - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 0.75rem; - background: rgba(0, 0, 0, 0.15); - overflow: hidden; -} - -.recent-songs-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.65rem 1rem; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.06); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.recent-songs-list { - max-height: 320px; - overflow-y: auto; -} - -.recent-song-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.55rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.recent-song-item:last-child { - border-bottom: none; -} - -.recent-song-item:hover { - background: rgba(255, 255, 255, 0.05); -} - -.recent-song-rank { - flex-shrink: 0; - width: 1.5rem; - text-align: right; - font-size: 0.8rem; - font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.4); -} - -.recent-song-title { - flex: 1; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 500; - color: rgba(255, 255, 255, 0.9); -} - -.recent-song-item .play-count { - flex-shrink: 0; - min-width: 3rem; - text-align: center; -} - -.recent-song-date { - flex-shrink: 0; - width: 6.5rem; - text-align: right; - font-size: 0.8rem; - font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.5); -} - -/* ===== Radio Admin ===== */ - -.add-button { - padding: 0.75rem 1.5rem; - font-size: 0.9rem; - background: rgba(76, 175, 80, 0.3); - color: white; -} - -.add-button:hover { - background: rgba(76, 175, 80, 0.8); -} - -.radio-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); - gap: 1.5rem; -} - -.radio-card { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 1rem; - padding: 1.5rem; - transition: all 0.3s ease; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05); -} - -.radio-card:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-3px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); - border-color: rgba(255, 255, 255, 0.2); -} - -.radio-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 1rem; -} - -.radio-name { - font-size: 1.2rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.9); - margin: 0; -} - -.radio-status { - padding: 0.25rem 0.75rem; - border-radius: 50px; - font-size: 0.8rem; - font-weight: 600; -} - -.radio-status.active { - background: rgba(52, 199, 89, 0.7); - color: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(20px); - border: 1px solid rgba(52, 199, 89, 0.4); -} - -.radio-status.inactive { - background: rgba(255, 59, 48, 0.7); - color: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 59, 48, 0.4); -} - -.radio-info { - margin-bottom: 1rem; -} - -.radio-info p { - margin: 0.5rem 0; - color: rgba(255, 255, 255, 0.8); - font-size: 0.9rem; -} - -.radio-url { - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 0.5rem; - border-radius: 0.5rem; - font-family: monospace; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.9); - word-break: break-all; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - white-space: wrap; - max-height: 60px; - overflow: auto; -} - -.radio-actions { - display: flex; - gap: 0.5rem; - margin-top: 1rem; -} - -.action-button { - background: rgba(255, 255, 255, 0.5); - backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 0.5rem 1rem; - border-radius: 0.5rem; - font-size: 0.8rem; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.action-button:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.action-button.edit { - background: rgba(0, 122, 255, 0.7); - color: white; -} - -.action-button.edit:hover { - background: rgba(0, 122, 255, 0.9); -} - -.action-button.delete { - background: rgba(255, 59, 48, 0.7); - color: white; -} - -.action-button.delete:hover { - background: rgba(255, 59, 48, 0.9); -} - -/* ===== Responsive ===== */ - -/* Tablet */ -@media (max-width: 1024px) { - .main { - padding: 1.5rem; - } - - .header-container { - padding: 1rem 1.5rem; - } - - .nav-button { - padding: 0.6rem 1rem; - font-size: 0.85rem; - } - - .stats-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .content-card { - padding: 1.5rem; - } - - .table .hide-md { - display: none; - } -} - -/* Mobile */ -@media (max-width: 768px) { - .header { - flex-direction: column; - gap: 1rem; - align-items: stretch; - } - - .title { - font-size: 1.5rem; - } - - .view-toggle { - width: 100%; - } - - .toggle-button { - flex: 1; - padding: 0.6rem 0.5rem; - } - - .header-container { - padding: 0.75rem 1rem; - } - - .header-content { - flex-wrap: wrap; - row-gap: 0.75rem; - } - - .logo { - font-size: 1.2rem; - } - - .header-actions { - margin-left: auto; - padding: 0 0.25rem; - } - - .nav { - order: 3; - width: 100%; - margin-left: 0; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .nav::-webkit-scrollbar { - display: none; - } - - .nav-button { - flex: 1; - white-space: nowrap; - padding: 0.6rem 0.9rem; - } - - .main { - padding: 1rem; - } - - .toolbar .view-toggle { - width: auto; - } - - .search-input { - max-width: none; - flex-basis: 100%; - } - - .content-card { - padding: 1rem; - min-height: 300px; - } - - .overview-grid { - grid-template-columns: minmax(0, 1fr); - gap: 1rem; - } - - .panel { - padding: 1rem; - } - - .chart-container { - height: 320px; - } - - .table { - font-size: 0.9rem; - min-width: 0; - } - - .table th, - .table td { - padding: 0.75rem 0.5rem; - } - - .table .hide-sm { - display: none; - } - - .user-info { - gap: 0.5rem; - } - - .user-avatar { - width: 32px; - height: 32px; - font-size: 1rem; - } - - .recent-song-date { - width: 5.2rem; - font-size: 0.75rem; - } - - .recent-song-item { - padding: 0.55rem 0.65rem; - } - - .radio-grid { - grid-template-columns: 1fr; - } - - .radio-actions { - flex-wrap: wrap; - } - - .action-button { - flex: 1; - padding: 0.65rem 1rem; - } - - .modal-content { - margin: 1rem; - width: calc(100% - 2rem); - max-height: 90dvh; - padding: 1.25rem; - } - - .form-actions { - flex-direction: column-reverse; - } - - .form-button { - width: 100%; - } -} - -/* Small phones */ -@media (max-width: 480px) { - .stats-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin-bottom: 1.25rem; - } - - .stat-card { - padding: 1rem; - } - - .stat-value { - font-size: 1.4rem; - } - - .stat-song-title { - font-size: 1rem; - } - - .stat-label { - font-size: 0.8rem; - } - - .rank-item { - gap: 0.55rem; - padding: 0.5rem 0.55rem; - } - - .rank-badge { - width: 1.5rem; - height: 1.5rem; - font-size: 0.75rem; - } - - .rank-label { - font-size: 0.85rem; - } - - .rank-value { - font-size: 0.78rem; - } - - .panel-header { - margin-bottom: 0.75rem; - } -} +@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap'); + +:root { + font-family: 'Quicksand', sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + background: linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%); +} + +#root { + display: block; + min-height: 100vh; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5); +} + +::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +/* ===== Common Styles ===== */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.title { + color: rgba(255, 255, 255, 0.9); + font-size: 2rem; + font-weight: 700; + margin: 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.glass-card { + background: rgba(0, 0, 0, 0.1); + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 1rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.glass-card:hover { + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + padding: 1.5rem; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.stat-value, +.stat-song-title { + color: rgba(255, 255, 255, 1); + margin: 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + overflow-wrap: anywhere; +} + +.stat-song-title { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; + font-size: 1.2rem; +} + +.stat-label { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + margin: 0.5rem 0 0 0; +} + +.content-card { + padding: 2rem; + min-height: 400px; + overflow: auto; +} + +.glass-button { + background: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 50px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.glass-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.25); + border-color: rgba(0, 0, 0, 0.4); +} + +.view-toggle { + display: flex; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50px; + padding: 0.25rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.toggle-button { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + padding: 0.5rem 1rem; + border-radius: 50px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.toggle-button.active { + background: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 1); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(20px); +} + +.table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th.sortable { + cursor: pointer; + user-select: none; +} + +.table th.sortable:hover { + color: rgba(255, 255, 255, 1); +} + +.sort-header { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.sort-arrow { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid rgba(255, 255, 255, 0.25); +} + +.sort-arrow.active { + border-top-color: rgba(255, 255, 255, 0.9); +} + +.sort-arrow.active.asc { + border-top: none; + border-bottom: 5px solid rgba(255, 255, 255, 0.9); +} + +.empty-state { + text-align: center; + color: rgba(255, 255, 255, 0.6); + padding: 2rem 1rem; +} + +.table th, +.table td { + text-align: left; + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.table th { + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.table td { + color: rgba(255, 255, 255, 0.8); +} + +.table td:first-child { + width: 10%; +} + +.table tr { + transition: background 0.5s ease; +} + +.table tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +.play-count { + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(20px); + color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 0.25rem 0.75rem; + border-radius: 50px; + font-size: 0.8rem; + font-weight: 600; + display: inline-block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.chart-container { + position: relative; + height: 400px; + width: 100%; +} + +/* ===== Toolbar (search / filters) ===== */ + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.search-input { + flex: 1; + min-width: 180px; + max-width: 340px; + border-radius: 50px; + padding: 0.6rem 1.1rem; +} + +/* ===== Overview page ===== */ + +.overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; +} + +.panel { + padding: 1.5rem; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.panel-title { + font-size: 1.05rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 0; +} + +.view-all-button { + padding: 0.35rem 0.9rem; + font-size: 0.75rem; + white-space: nowrap; +} + +.panel-empty { + color: rgba(255, 255, 255, 0.55); + font-size: 0.9rem; + padding: 0.5rem 0; + margin: 0; +} + +.rank-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding: 0; +} + +.rank-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.05); +} + +.rank-badge { + width: 1.7rem; + height: 1.7rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.8); +} + +.rank-badge.rank-1 { + background: #f5c542; + color: #3b2f00; +} + +.rank-badge.rank-2 { + background: #c8d1dc; + color: #2b3542; +} + +.rank-badge.rank-3 { + background: #d2996b; + color: #3d2412; +} + +.rank-info { + flex: 1; + min-width: 0; +} + +.rank-label { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rank-sublabel { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.55); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rank-value { + font-size: 0.85rem; + font-weight: 600; + color: #86b6ef; + white-space: nowrap; + flex-shrink: 0; +} + +/* ===== Pagination ===== */ + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +.pagination-info { + color: rgba(255, 255, 255, 0.6); + font-size: 0.85rem; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.pagination-button { + padding: 0.5rem 1.1rem; + font-size: 0.85rem; +} + +.pagination-button:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.pagination-page { + color: rgba(255, 255, 255, 0.8); + font-size: 0.85rem; + font-variant-numeric: tabular-nums; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + height: 50vh; + color: rgba(255, 255, 255, 0.7); + font-size: 1.1rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + color: rgba(255, 255, 255, 0.9); + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 0.5rem; +} + +.form-input, +.form-textarea, +.form-select { + width: 100%; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.9rem; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + overflow-y: auto; +} + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.4); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.modal-content { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 1rem; + padding: 2rem; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.modal-title { + color: rgba(255, 255, 255, 0.9); + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.close-button { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + font-size: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.close-button:hover { + color: rgba(255, 255, 255, 0.9); +} + +.form-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + color: rgba(255, 255, 255, 0.8); +} + +.form-checkbox input { + width: auto; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; +} + +.form-button { + padding: 0.75rem 1.5rem; + font-size: 0.9rem; +} + +.form-button.secondary { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + border-color: rgba(255, 255, 255, 0.2); +} + +/* ===== Dashboard Layout ===== */ + +.header-container { + background: rgba(0, 0, 0, 0.1); + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + border: 1px solid rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 1rem 2rem; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.header-content { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 1rem; +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + color: rgba(255, 255, 255, 0.9); + font-size: 1.5rem; + font-weight: 700; +} + +.logo-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.nav { + display: flex; + gap: 0.5rem; + align-items: center; + margin-left: auto; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.nav-button { + padding: 0.75rem 1.5rem; + font-size: 0.9rem; + font-weight: 500; + position: relative; + overflow: hidden; +} + +.nav-button:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.nav-button.active { + color: rgba(255, 255, 255, 1); + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.3); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.nav-button.active::before { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 50%; + height: 2px; + background: rgba(255, 255, 255, 0.8); + border-radius: 1px; +} + +.main { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.tab-content { + opacity: 0; + animation: fadeIn 0.5s ease forwards; +} + +.login-icon { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + width: 20px; +} + +@keyframes fadeIn { + to { + opacity: 1; + } +} + +/* ===== Spinner ===== */ + +.spinner { + width: 100px; + height: 100px; + border-radius: 50%; + display: inline-block; + border-top: 3px solid #fff; + border-right: 3px solid transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* ===== Error ===== */ + +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #fb8997; + font-family: Arial, sans-serif; +} + +/* ===== Song Stats ===== */ + +.song-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.song-title { + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.song-artist { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.6); +} + +/* ===== User Stats ===== */ + +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + font-size: 1.2rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); +} + +.user-details { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.username { + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.discriminator { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); +} + +.unique-song { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + color: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 50px; + font-size: 0.8rem; + display: inline-block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* ===== Recently Played (expanded user row) ===== */ + +.table tr.recent-songs-row:hover { + background: transparent; +} + +.recent-songs { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.75rem; + background: rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.recent-songs-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.65rem 1rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.recent-songs-list { + max-height: 320px; + overflow-y: auto; +} + +.recent-song-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.recent-song-item:last-child { + border-bottom: none; +} + +.recent-song-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.recent-song-rank { + flex-shrink: 0; + width: 1.5rem; + text-align: right; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.4); +} + +.recent-song-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); +} + +.recent-song-item .play-count { + flex-shrink: 0; + min-width: 3rem; + text-align: center; +} + +.recent-song-date { + flex-shrink: 0; + width: 6.5rem; + text-align: right; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.5); +} + +/* ===== Radio Admin ===== */ + +.add-button { + padding: 0.75rem 1.5rem; + font-size: 0.9rem; + background: rgba(76, 175, 80, 0.3); + color: white; +} + +.add-button:hover { + background: rgba(76, 175, 80, 0.8); +} + +.radio-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.radio-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 1rem; + padding: 1.5rem; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05); +} + +.radio-card:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-3px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.radio-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.radio-name { + font-size: 1.2rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + margin: 0; +} + +.radio-status { + padding: 0.25rem 0.75rem; + border-radius: 50px; + font-size: 0.8rem; + font-weight: 600; +} + +.radio-status.active { + background: rgba(52, 199, 89, 0.7); + color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(20px); + border: 1px solid rgba(52, 199, 89, 0.4); +} + +.radio-status.inactive { + background: rgba(255, 59, 48, 0.7); + color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 59, 48, 0.4); +} + +.radio-info { + margin-bottom: 1rem; +} + +.radio-info p { + margin: 0.5rem 0; + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; +} + +.radio-url { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.5rem; + border-radius: 0.5rem; + font-family: monospace; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.9); + word-break: break-all; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + white-space: wrap; + max-height: 60px; + overflow: auto; +} + +.radio-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.action-button { + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.action-button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.action-button.edit { + background: rgba(0, 122, 255, 0.7); + color: white; +} + +.action-button.edit:hover { + background: rgba(0, 122, 255, 0.9); +} + +.action-button.delete { + background: rgba(255, 59, 48, 0.7); + color: white; +} + +.action-button.delete:hover { + background: rgba(255, 59, 48, 0.9); +} + +/* ===== Responsive ===== */ + +/* Tablet */ +@media (max-width: 1024px) { + .main { + padding: 1.5rem; + } + + .header-container { + padding: 1rem 1.5rem; + } + + .nav-button { + padding: 0.6rem 1rem; + font-size: 0.85rem; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .content-card { + padding: 1.5rem; + } + + .table .hide-md { + display: none; + } +} + +/* Mobile */ +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .title { + font-size: 1.5rem; + } + + .view-toggle { + width: 100%; + } + + .toggle-button { + flex: 1; + padding: 0.6rem 0.5rem; + } + + .header-container { + padding: 0.75rem 1rem; + } + + .header-content { + flex-wrap: wrap; + row-gap: 0.75rem; + } + + .logo { + font-size: 1.2rem; + } + + .header-actions { + margin-left: auto; + padding: 0 0.25rem; + } + + .nav { + order: 3; + width: 100%; + margin-left: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .nav::-webkit-scrollbar { + display: none; + } + + .nav-button { + flex: 1; + white-space: nowrap; + padding: 0.6rem 0.9rem; + } + + .main { + padding: 1rem; + } + + .toolbar .view-toggle { + width: auto; + } + + .search-input { + max-width: none; + flex-basis: 100%; + } + + .content-card { + padding: 1rem; + min-height: 300px; + } + + .overview-grid { + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + } + + .panel { + padding: 1rem; + } + + .chart-container { + height: 320px; + } + + .table { + font-size: 0.9rem; + min-width: 0; + } + + .table th, + .table td { + padding: 0.75rem 0.5rem; + } + + .table .hide-sm { + display: none; + } + + .user-info { + gap: 0.5rem; + } + + .user-avatar { + width: 32px; + height: 32px; + font-size: 1rem; + } + + .recent-song-date { + width: 5.2rem; + font-size: 0.75rem; + } + + .recent-song-item { + padding: 0.55rem 0.65rem; + } + + .radio-grid { + grid-template-columns: 1fr; + } + + .radio-actions { + flex-wrap: wrap; + } + + .action-button { + flex: 1; + padding: 0.65rem 1rem; + } + + .modal-content { + margin: 1rem; + width: calc(100% - 2rem); + max-height: 90dvh; + padding: 1.25rem; + } + + .form-actions { + flex-direction: column-reverse; + } + + .form-button { + width: 100%; + } +} + +/* Small phones */ +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1.25rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-value { + font-size: 1.4rem; + } + + .stat-song-title { + font-size: 1rem; + } + + .stat-label { + font-size: 0.8rem; + } + + .rank-item { + gap: 0.55rem; + padding: 0.5rem 0.55rem; + } + + .rank-badge { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; + } + + .rank-label { + font-size: 0.85rem; + } + + .rank-value { + font-size: 0.78rem; + } + + .panel-header { + margin-bottom: 0.75rem; + } +} diff --git a/src/UI/App/src/interfaces/common.interfaces.ts b/src/UI/App/src/interfaces/common.interfaces.ts index 46165a4..4d9c0bb 100644 --- a/src/UI/App/src/interfaces/common.interfaces.ts +++ b/src/UI/App/src/interfaces/common.interfaces.ts @@ -1,31 +1,31 @@ -export interface RecentSong { - title: string; - totalPlays: number; - playedAt: string; -} - -export interface UserStatsDto { - username: string; - totalPlays: number; - uniqueSongs: number; - memberSince: Date; - lastPlayed?: Date | null; - displayName?: string | null; - recentSongs: RecentSong[]; -} - -export interface SongStat { - title: string; - artist?: string | null; - playCount: number; - lastPlayed: Date; -} - -export interface RadioSource { - id: string; - name: string; - sourceUrl: string; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} +export interface RecentSong { + title: string; + totalPlays: number; + playedAt: string; +} + +export interface UserStatsDto { + username: string; + totalPlays: number; + uniqueSongs: number; + memberSince: Date; + lastPlayed?: Date | null; + displayName?: string | null; + recentSongs: RecentSong[]; +} + +export interface SongStat { + title: string; + artist?: string | null; + playCount: number; + lastPlayed: Date; +} + +export interface RadioSource { + id: string; + name: string; + sourceUrl: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/UI/App/src/layouts/DashboardLayout.tsx b/src/UI/App/src/layouts/DashboardLayout.tsx index 5f53709..c78798a 100644 --- a/src/UI/App/src/layouts/DashboardLayout.tsx +++ b/src/UI/App/src/layouts/DashboardLayout.tsx @@ -1,83 +1,83 @@ -import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useAuth } from '../hooks/useAuth'; - -export function DashboardLayout() { - const navigate = useNavigate(); - const { isAuthenticated, logout } = useAuth(); - const routerState = useRouterState(); - const currentPath = routerState.location.pathname; - - function isActive(path: string): string { - return currentPath === path ? 'active' : ''; - } - - function handleNavigation(path: string) { - navigate({ to: path }); - } - - function handleLogout() { - logout(); - navigate({ to: '/' }); - } - - return ( - <> -
-
-
- Logo - Rytho Dashboard -
- -
- {!isAuthenticated ? ( -
handleNavigation('/login')} - > - Login -
- ) : ( -
- Logout -
- )} -
-
-
- -
-
- -
-
- - ); -} +import { Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; +import { useAuth } from '../hooks/useAuth'; + +export function DashboardLayout() { + const navigate = useNavigate(); + const { isAuthenticated, logout } = useAuth(); + const routerState = useRouterState(); + const currentPath = routerState.location.pathname; + + function isActive(path: string): string { + return currentPath === path ? 'active' : ''; + } + + function handleNavigation(path: string) { + navigate({ to: path }); + } + + function handleLogout() { + logout(); + navigate({ to: '/' }); + } + + return ( + <> +
+
+
+ Logo + Rytho Dashboard +
+ +
+ {!isAuthenticated ? ( +
handleNavigation('/login')} + > + Login +
+ ) : ( +
+ Logout +
+ )} +
+
+
+ +
+
+ +
+
+ + ); +} diff --git a/src/UI/App/src/main.tsx b/src/UI/App/src/main.tsx index 2239905..d2b8583 100644 --- a/src/UI/App/src/main.tsx +++ b/src/UI/App/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - - - , -); +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/UI/App/src/pages/Login.tsx b/src/UI/App/src/pages/Login.tsx index 287cdff..765d724 100644 --- a/src/UI/App/src/pages/Login.tsx +++ b/src/UI/App/src/pages/Login.tsx @@ -1,69 +1,69 @@ -import { useState } from 'react'; -import { useNavigate } from '@tanstack/react-router'; -import { loginUser } from '../services/user-service'; -import { useAuth } from '../hooks/useAuth'; - -export function Login() { - const [userName, setUserName] = useState(''); - const [password, setPassword] = useState(''); - const { login } = useAuth(); - const navigate = useNavigate(); - - async function handleLogin(e: React.FormEvent) { - e.preventDefault(); - if (userName && password) { - try { - const { token } = await loginUser(userName, password); - login(token); - navigate({ to: '/admin' }); - } catch { - alert('Login failed. Please check your credentials.'); - } - } else { - alert('Username and password cannot be empty.'); - } - } - - return ( -
-
-
-

Login

-
-
-
- - setUserName(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
-
- -
-
-
-
- ); -} +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { loginUser } from '../services/user-service'; +import { useAuth } from '../hooks/useAuth'; + +export function Login() { + const [userName, setUserName] = useState(''); + const [password, setPassword] = useState(''); + const { login } = useAuth(); + const navigate = useNavigate(); + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + if (userName && password) { + try { + const { token } = await loginUser(userName, password); + login(token); + navigate({ to: '/admin' }); + } catch { + alert('Login failed. Please check your credentials.'); + } + } else { + alert('Username and password cannot be empty.'); + } + } + + return ( +
+
+
+

Login

+
+
+
+ + setUserName(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ +
+
+
+
+ ); +} diff --git a/src/UI/App/src/pages/RadioAdmin.tsx b/src/UI/App/src/pages/RadioAdmin.tsx index 50427fb..6ee2ce9 100644 --- a/src/UI/App/src/pages/RadioAdmin.tsx +++ b/src/UI/App/src/pages/RadioAdmin.tsx @@ -1,266 +1,266 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from '@tanstack/react-router'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { useAuth } from '../hooks/useAuth'; -import type { RadioSource } from '../interfaces/common.interfaces'; -import { - loadRadioSources, - updateRadioSource, - deleteRadioSource, - addRadioSource, -} from '../services/radio-source-service'; -import { LoadingSpinner } from '../components/LoadingSpinner'; -import { AppError } from '../components/AppError'; - -export function RadioAdmin() { - const [showAddForm, setShowAddForm] = useState(false); - const [editingStation, setEditingStation] = useState( - null, - ); - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const { logout } = useAuth(); - - const { - data: radioStations, - isLoading, - error, - } = useQuery({ - queryKey: ['radioSources'], - queryFn: loadRadioSources, - }); - - // The route guard only checks that a token exists; an expired one still - // reaches this page and fails with 401, so send the user back to login. - const unauthorized = error !== null && String(error).includes('401'); - useEffect(() => { - if (unauthorized) { - logout(); - navigate({ to: '/login' }); - } - }, [unauthorized, logout, navigate]); - - const deleteMutation = useMutation({ - mutationFn: deleteRadioSource, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['radioSources'] }); - toast.success('Radio station deleted successfully.'); - }, - onError: () => { - toast.error('Failed to delete radio station. Please try again.'); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ - id, - name, - sourceUrl, - isActive, - }: { - id: string; - name: string; - sourceUrl: string; - isActive: boolean; - }) => updateRadioSource(id, name, sourceUrl, isActive), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['radioSources'] }); - toast.success('Radio station updated successfully.'); - }, - onError: () => { - toast.error('Failed to update radio station. Please try again.'); - }, - }); - - const addMutation = useMutation({ - mutationFn: ({ name, sourceUrl }: { name: string; sourceUrl: string }) => - addRadioSource(name, sourceUrl), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['radioSources'] }); - toast.success('Radio station added successfully.'); - }, - onError: (e) => { - toast.error( - `Failed to add radio station: ${e instanceof Error ? e.message : 'Unknown error'}`, - ); - }, - }); - - if (isLoading) return ; - if (unauthorized) return null; - if (error) return ; - if (!radioStations) return null; - - const totalStations = radioStations.length; - const activeStations = radioStations.filter((s) => s.isActive).length; - - function hideModal() { - setShowAddForm(false); - setEditingStation(null); - } - - function editStation(station: RadioSource) { - setEditingStation({ ...station }); - setShowAddForm(true); - } - - function handleDelete(stationId: string) { - if (confirm('Are you sure you want to delete this radio station?')) { - deleteMutation.mutate(stationId); - } - } - - async function handleFormSubmit(e: React.FormEvent) { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const name = formData.get('name') as string; - const sourceUrl = formData.get('url') as string; - const isActive = formData.get('isActive') === 'on'; - - try { - if (editingStation) { - await updateMutation.mutateAsync({ - id: editingStation.id, - name, - sourceUrl, - isActive, - }); - } else { - await addMutation.mutateAsync({ name, sourceUrl }); - } - hideModal(); - } catch { - // Error toast is handled by mutation onError callbacks - } - } - - return ( - <> -
-

Radio Station Management

- -
- -
-
-

{totalStations}

-

Total Stations

-
-
-

{activeStations}

-

Active Stations

-
-
- -
-
- {radioStations.map((station) => ( -
-
-

{station.name}

- - {station.isActive ? 'Active' : 'Inactive'} - -
-
-
- {station.sourceUrl} -
-
-
- - -
-
- ))} -
-
- - {showAddForm && ( -
e.target === e.currentTarget && hideModal()} - > -
-
-

- {editingStation - ? 'Edit Radio Station' - : 'Add New Radio Station'} -

- -
-
-
- - -
-
- -