Full Code of christomitov/soundbored for AI

main 337ac8aa8a74 cached
199 files
628.2 KB
171.1k tokens
1000 symbols
1 requests
Download .txt
Showing preview only (684K chars total). Download the full file or copy to clipboard to get everything.
Repository: christomitov/soundbored
Branch: main
Commit: 337ac8aa8a74
Files: 199
Total size: 628.2 KB

Directory structure:
gitextract__nmkll40/

├── .credo.exs
├── .dockerignore
├── .ex_dna.exs
├── .formatter.exs
├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .tool-versions
├── AGENTS.md
├── Dockerfile
├── README.md
├── assets/
│   ├── css/
│   │   └── app.css
│   ├── js/
│   │   ├── app.js
│   │   └── hooks/
│   │       └── local_player.js
│   ├── tailwind.config.js
│   └── vendor/
│       └── topbar.js
├── config/
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── runtime.exs
│   └── test.exs
├── coveralls.json
├── docker-compose.yml
├── docs/
│   ├── plans/
│   │   ├── discord-role-gated-access.md
│   │   └── discord-role-gated-access.md.tasks.json
│   └── specs/
│       ├── discord-role-gated-access.md
│       ├── voice-auto-join-idle-leave.md
│       └── youtube-playback.md
├── entrypoint.sh
├── lib/
│   ├── soundboard/
│   │   ├── accounts/
│   │   │   ├── api_token.ex
│   │   │   ├── api_tokens.ex
│   │   │   └── user.ex
│   │   ├── accounts.ex
│   │   ├── application.ex
│   │   ├── audio_player/
│   │   │   ├── notifier.ex
│   │   │   ├── playback_engine.ex
│   │   │   ├── playback_queue.ex
│   │   │   ├── sound_library.ex
│   │   │   └── voice_session.ex
│   │   ├── audio_player.ex
│   │   ├── discord/
│   │   │   ├── bot_identity.ex
│   │   │   ├── consumer.ex
│   │   │   ├── guild_cache.ex
│   │   │   ├── handler/
│   │   │   │   ├── auto_join_policy.ex
│   │   │   │   ├── command_handler.ex
│   │   │   │   ├── idle_timeout_policy.ex
│   │   │   │   ├── sound_effects.ex
│   │   │   │   ├── voice_commands.ex
│   │   │   │   ├── voice_presence.ex
│   │   │   │   └── voice_runtime.ex
│   │   │   ├── handler.ex
│   │   │   ├── message.ex
│   │   │   ├── role_checker.ex
│   │   │   ├── runtime_capability.ex
│   │   │   └── voice.ex
│   │   ├── favorites/
│   │   │   └── favorite.ex
│   │   ├── favorites.ex
│   │   ├── public_url.ex
│   │   ├── pubsub_topics.ex
│   │   ├── release.ex
│   │   ├── repo.ex
│   │   ├── sound.ex
│   │   ├── sound_tag.ex
│   │   ├── sounds/
│   │   │   ├── management.ex
│   │   │   ├── tags.ex
│   │   │   ├── uploads/
│   │   │   │   ├── create_request.ex
│   │   │   │   ├── creator.ex
│   │   │   │   ├── normalizer.ex
│   │   │   │   └── source.ex
│   │   │   └── uploads.ex
│   │   ├── sounds.ex
│   │   ├── stats/
│   │   │   └── play.ex
│   │   ├── stats.ex
│   │   ├── tag.ex
│   │   ├── uploads_path.ex
│   │   ├── user_sound_setting.ex
│   │   └── volume.ex
│   ├── soundboard.ex
│   ├── soundboard_web/
│   │   ├── components/
│   │   │   ├── core_components.ex
│   │   │   ├── flash_component.ex
│   │   │   ├── layouts/
│   │   │   │   ├── app.html.heex
│   │   │   │   ├── navbar.ex
│   │   │   │   └── root.html.heex
│   │   │   ├── layouts.ex
│   │   │   └── soundboard/
│   │   │       ├── delete_modal.ex
│   │   │       ├── edit_modal.ex
│   │   │       ├── helpers.ex
│   │   │       ├── tag_components.ex
│   │   │       ├── upload_modal.ex
│   │   │       └── volume_control.ex
│   │   ├── controllers/
│   │   │   ├── api/
│   │   │   │   └── sound_controller.ex
│   │   │   ├── auth_controller.ex
│   │   │   ├── error_html.ex
│   │   │   ├── error_json.ex
│   │   │   └── upload_controller.ex
│   │   ├── endpoint.ex
│   │   ├── gettext.ex
│   │   ├── live/
│   │   │   ├── favorites_live.ex
│   │   │   ├── favorites_live.html.heex
│   │   │   ├── settings_live.ex
│   │   │   ├── soundboard_live/
│   │   │   │   ├── edit_flow.ex
│   │   │   │   └── upload_flow.ex
│   │   │   ├── soundboard_live.ex
│   │   │   ├── soundboard_live.html.heex
│   │   │   ├── stats_live.ex
│   │   │   └── support/
│   │   │       ├── flash_helpers.ex
│   │   │       ├── live_tags.ex
│   │   │       ├── presence_live.ex
│   │   │       ├── sound_playback.ex
│   │   │       └── tag_form.ex
│   │   ├── plugs/
│   │   │   ├── api_auth.ex
│   │   │   ├── basic_auth.ex
│   │   │   └── role_check.ex
│   │   ├── presence.ex
│   │   ├── presence_handler.ex
│   │   ├── router.ex
│   │   ├── sound_helpers.ex
│   │   ├── soundboard/
│   │   │   └── sound_filter.ex
│   │   └── telemetry.ex
│   └── soundboard_web.ex
├── mix.exs
├── priv/
│   ├── gettext/
│   │   ├── en/
│   │   │   └── LC_MESSAGES/
│   │   │       └── errors.po
│   │   └── errors.pot
│   ├── repo/
│   │   ├── migrations/
│   │   │   ├── .formatter.exs
│   │   │   ├── 20250101213201_create_sounds.exs
│   │   │   ├── 20250101213717_create_tags.exs
│   │   │   ├── 20250101231744_create_users.exs
│   │   │   ├── 20250102212120_create_plays.exs
│   │   │   ├── 20250102212121_create_favorites.exs
│   │   │   ├── 20250102212122_add_user_id_to_sounds.exs
│   │   │   ├── 20250102212123_change_favorites_filename_to_sound_id.exs
│   │   │   ├── 20250102212124_add_index_to_plays.exs
│   │   │   ├── 20250102212125_add_join_leave_flags_to_sounds.exs
│   │   │   ├── 20250102212126_add_url_to_sounds.exs
│   │   │   ├── 20250218214831_create_user_sound_settings.exs
│   │   │   ├── 20250218214832_remove_join_leave_flags_from_sounds.exs
│   │   │   ├── 20250218220000_create_api_tokens.exs
│   │   │   ├── 20250218223000_add_token_plain_to_api_tokens.exs
│   │   │   ├── 20250310120000_add_volume_to_sounds.exs
│   │   │   ├── 20260306150000_add_sound_id_to_plays.exs
│   │   │   ├── 20260306151000_finalize_favorites_and_sound_tags_migrations.exs
│   │   │   └── 20260307211000_rename_sound_name_to_played_filename_in_plays.exs
│   │   └── seeds.exs
│   └── static/
│       ├── manifest.json
│       └── robots.txt
└── test/
    ├── soundboard/
    │   ├── accounts/
    │   │   └── api_tokens_test.exs
    │   ├── accounts_test.exs
    │   ├── audio_player/
    │   │   ├── playback_engine_test.exs
    │   │   ├── playback_queue_test.exs
    │   │   └── sound_library_test.exs
    │   ├── discord/
    │   │   ├── bot_identity_test.exs
    │   │   ├── handler/
    │   │   │   ├── auto_join_policy_test.exs
    │   │   │   ├── command_handler_test.exs
    │   │   │   ├── idle_timeout_policy_test.exs
    │   │   │   ├── voice_presence_test.exs
    │   │   │   └── voice_runtime_test.exs
    │   │   ├── role_checker_test.exs
    │   │   ├── runtime_capability_test.exs
    │   │   └── voice_test.exs
    │   ├── favorites_test.exs
    │   ├── migrations/
    │   │   └── data_migrations_test.exs
    │   ├── public_url_test.exs
    │   ├── pubsub_topics_test.exs
    │   ├── sound_tag_test.exs
    │   ├── sound_test.exs
    │   ├── sounds/
    │   │   ├── management_test.exs
    │   │   ├── sound_settings_test.exs
    │   │   ├── tags_test.exs
    │   │   └── uploads_test.exs
    │   ├── stats_test.exs
    │   ├── tags/
    │   │   └── tag_test.exs
    │   ├── uploads_path_test.exs
    │   └── volume_test.exs
    ├── soundboard_test.exs
    ├── soundboard_web/
    │   ├── audio_player_test.exs
    │   ├── components/
    │   │   ├── layouts/
    │   │   │   └── navbar_test.exs
    │   │   └── soundboard/
    │   │       ├── edit_modal_test.exs
    │   │       └── upload_modal_test.exs
    │   ├── controllers/
    │   │   ├── api/
    │   │   │   └── sound_controller_test.exs
    │   │   ├── auth_controller_test.exs
    │   │   └── upload_controller_test.exs
    │   ├── discord_handler_test.exs
    │   ├── eda_consumer_test.exs
    │   ├── live/
    │   │   ├── favorites_live_test.exs
    │   │   ├── settings_live_test.exs
    │   │   ├── soundboard_live/
    │   │   │   ├── edit_flow_test.exs
    │   │   │   └── upload_flow_test.exs
    │   │   ├── soundboard_live_test.exs
    │   │   └── stats_live_test.exs
    │   ├── plugs/
    │   │   ├── api_auth_db_token_test.exs
    │   │   ├── basic_auth_test.exs
    │   │   └── role_check_test.exs
    │   ├── presence_handler_test.exs
    │   ├── sound_helpers_test.exs
    │   └── soundboard/
    │       └── sound_filter_test.exs
    ├── support/
    │   ├── conn_case.ex
    │   ├── data_case.ex
    │   └── test_helpers.ex
    └── test_helper.exs

================================================
FILE CONTENTS
================================================

================================================
FILE: .credo.exs
================================================
%{
  configs: [
    %{
      name: "default",
      files: %{
        included: [
          "lib/",
          "src/",
          "test/",
          "web/",
          "apps/*/lib/",
          "apps/*/src/",
          "apps/*/test/",
          "apps/*/web/"
        ],
        excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
      },
      plugins: [],
      requires: [],
      strict: false,
      parse_timeout: 5000,
      color: true,
      checks: %{
        enabled: [
          {Credo.Check.Consistency.ExceptionNames, []},
          {Credo.Check.Consistency.LineEndings, []},
          {Credo.Check.Consistency.ParameterPatternMatching, []},
          {Credo.Check.Consistency.SpaceAroundOperators, []},
          {Credo.Check.Consistency.SpaceInParentheses, []},
          {Credo.Check.Consistency.TabsOrSpaces, []},
          {Credo.Check.Design.AliasUsage,
           [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
          {Credo.Check.Design.TagFIXME, []},
          {Credo.Check.Design.TagTODO, [exit_status: 2]},
          {Credo.Check.Readability.AliasOrder, []},
          {Credo.Check.Readability.FunctionNames, []},
          {Credo.Check.Readability.LargeNumbers, []},
          {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
          {Credo.Check.Readability.ModuleAttributeNames, []},
          {Credo.Check.Readability.ModuleDoc, []},
          {Credo.Check.Readability.ModuleNames, []},
          {Credo.Check.Readability.ParenthesesInCondition, []},
          {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
          {Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
          {Credo.Check.Readability.PredicateFunctionNames, []},
          {Credo.Check.Readability.PreferImplicitTry, []},
          {Credo.Check.Readability.RedundantBlankLines, []},
          {Credo.Check.Readability.Semicolons, []},
          {Credo.Check.Readability.SpaceAfterCommas, []},
          {Credo.Check.Readability.StringSigils, []},
          {Credo.Check.Readability.TrailingBlankLine, []},
          {Credo.Check.Readability.TrailingWhiteSpace, []},
          {Credo.Check.Readability.UnnecessaryAliasExpansion, []},
          {Credo.Check.Readability.VariableNames, []},
          {Credo.Check.Readability.WithSingleClause, []},
          {Credo.Check.Refactor.Apply, []},
          {Credo.Check.Refactor.CondStatements, []},
          {Credo.Check.Refactor.CyclomaticComplexity, []},
          {Credo.Check.Refactor.FilterCount, []},
          {Credo.Check.Refactor.FilterFilter, []},
          {Credo.Check.Refactor.FunctionArity, []},
          {Credo.Check.Refactor.LongQuoteBlocks, []},
          {Credo.Check.Refactor.MapJoin, []},
          {Credo.Check.Refactor.MatchInCondition, []},
          {Credo.Check.Refactor.NegatedConditionsInUnless, []},
          {Credo.Check.Refactor.NegatedConditionsWithElse, []},
          {Credo.Check.Refactor.Nesting, []},
          {Credo.Check.Refactor.RedundantWithClauseResult, []},
          {Credo.Check.Refactor.RejectReject, []},
          {Credo.Check.Refactor.UnlessWithElse, []},
          {Credo.Check.Refactor.WithClauses, []},
          {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
          {Credo.Check.Warning.BoolOperationOnSameValues, []},
          {Credo.Check.Warning.Dbg, []},
          {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
          {Credo.Check.Warning.IExPry, []},
          {Credo.Check.Warning.IoInspect, []},
          {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
          {Credo.Check.Warning.OperationOnSameValues, []},
          {Credo.Check.Warning.OperationWithConstantResult, []},
          {Credo.Check.Warning.RaiseInsideRescue, []},
          {Credo.Check.Warning.SpecWithStruct, []},
          {Credo.Check.Warning.UnsafeExec, []},
          {Credo.Check.Warning.UnusedEnumOperation, []},
          {Credo.Check.Warning.UnusedFileOperation, []},
          {Credo.Check.Warning.UnusedKeywordOperation, []},
          {Credo.Check.Warning.UnusedListOperation, []},
          {Credo.Check.Warning.UnusedPathOperation, []},
          {Credo.Check.Warning.UnusedRegexOperation, []},
          {Credo.Check.Warning.UnusedStringOperation, []},
          {Credo.Check.Warning.UnusedTupleOperation, []},
          {Credo.Check.Warning.WrongTestFileExtension, []},

          # ExSlop Warning checks
          {ExSlop.Check.Warning.BlanketRescue, []},
          {ExSlop.Check.Warning.RescueWithoutReraise, []},
          {ExSlop.Check.Warning.RepoAllThenFilter, []},
          {ExSlop.Check.Warning.QueryInEnumMap, []},
          {ExSlop.Check.Warning.GenserverAsKvStore, []},

          # ExSlop Refactor checks
          {ExSlop.Check.Refactor.FilterNil, []},
          {ExSlop.Check.Refactor.RejectNil, []},
          {ExSlop.Check.Refactor.ReduceAsMap, []},
          {ExSlop.Check.Refactor.MapIntoLiteral, []},
          {ExSlop.Check.Refactor.IdentityPassthrough, []},
          {ExSlop.Check.Refactor.IdentityMap, []},
          {ExSlop.Check.Refactor.CaseTrueFalse, []},
          {ExSlop.Check.Refactor.TryRescueWithSafeAlternative, []},
          {ExSlop.Check.Refactor.WithIdentityElse, []},
          {ExSlop.Check.Refactor.WithIdentityDo, []},
          {ExSlop.Check.Refactor.SortThenReverse, []},
          {ExSlop.Check.Refactor.StringConcatInReduce, []},

          # ExSlop Readability checks
          {ExSlop.Check.Readability.NarratorDoc, []},
          {ExSlop.Check.Readability.DocFalseOnPublicFunction, []},
          {ExSlop.Check.Readability.BoilerplateDocParams, []},
          {ExSlop.Check.Readability.ObviousComment, []},
          {ExSlop.Check.Readability.StepComment, []},
          {ExSlop.Check.Readability.NarratorComment, []}
        ],
        disabled: [
          {Credo.Check.Refactor.UtcNowTruncate, []},
          {Credo.Check.Consistency.MultiAliasImportRequireUse, []},
          {Credo.Check.Consistency.UnusedVariableNames, []},
          {Credo.Check.Design.DuplicatedCode, []},
          {Credo.Check.Design.SkipTestWithoutComment, []},
          {Credo.Check.Readability.AliasAs, []},
          {Credo.Check.Readability.BlockPipe, []},
          {Credo.Check.Readability.ImplTrue, []},
          {Credo.Check.Readability.MultiAlias, []},
          {Credo.Check.Readability.NestedFunctionCalls, []},
          {Credo.Check.Readability.OneArityFunctionInPipe, []},
          {Credo.Check.Readability.OnePipePerLine, []},
          {Credo.Check.Readability.SeparateAliasRequire, []},
          {Credo.Check.Readability.SingleFunctionToBlockPipe, []},
          {Credo.Check.Readability.SinglePipe, []},
          {Credo.Check.Readability.Specs, []},
          {Credo.Check.Readability.StrictModuleLayout, []},
          {Credo.Check.Readability.WithCustomTaggedTuple, []},
          {Credo.Check.Refactor.ABCSize, []},
          {Credo.Check.Refactor.AppendSingleItem, []},
          {Credo.Check.Refactor.DoubleBooleanNegation, []},
          {Credo.Check.Refactor.FilterReject, []},
          {Credo.Check.Refactor.IoPuts, []},
          {Credo.Check.Refactor.MapMap, []},
          {Credo.Check.Refactor.ModuleDependencies, []},
          {Credo.Check.Refactor.NegatedIsNil, []},
          {Credo.Check.Refactor.PassAsyncInTestCases, []},
          {Credo.Check.Refactor.PipeChainStart, []},
          {Credo.Check.Refactor.RejectFilter, []},
          {Credo.Check.Refactor.VariableRebinding, []},
          {Credo.Check.Warning.LazyLogging, []},
          {Credo.Check.Warning.LeakyEnvironment, []},
          {Credo.Check.Warning.MapGetUnsafePass, []},
          {Credo.Check.Warning.MixEnv, []},
          {Credo.Check.Warning.UnsafeToAtom, []}
        ]
      }
    }
  ]
}


================================================
FILE: .dockerignore
================================================
.dockerignore
.git*
test
.env*
*.md
coveralls.json
docker-compose.yml
Dockerfile


================================================
FILE: .ex_dna.exs
================================================
%{
  min_mass: 25,
  ignore: ["lib/soundboard_web/templates/**"],
  excluded_macros: [:@, :schema, :pipe_through, :plug],
  normalize_pipes: true
}


================================================
FILE: .formatter.exs
================================================
[
  import_deps: [:ecto, :ecto_sql, :phoenix],
  subdirectories: ["priv/*/migrations"],
  plugins: [Phoenix.LiveView.HTMLFormatter],
  inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]


================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "mix"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 1


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI/CD Pipeline

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  test:
    name: Build and test
    runs-on: ubuntu-latest
    environment: Builder

    steps:
      - uses: actions/checkout@v5
      
      - name: Set up Elixir
        uses: erlef/setup-beam@v1
        with:
          elixir-version: '1.19.x'
          otp-version: '27.x'
          
      - name: Restore dependencies cache
        uses: actions/cache@v4
        with:
          path: deps
          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
          restore-keys: ${{ runner.os }}-mix-
          
      - name: Install dependencies
        run: mix deps.get
        
      - name: Compile (warnings as errors)
        run: mix compile --warnings-as-errors
        
      - name: Install SQLite dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y sqlite3 libsqlite3-dev
          
      - name: Run tests with coverage
        run: mix coveralls.github
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
          MIX_ENV: test
          DISCORD_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
          
      - name: Verify minimum coverage
        run: |
          COVERAGE=$(mix coveralls | grep "\[TOTAL\]" | awk '{print $2}' | sed 's/%//')
          MINIMUM=80.0
          
          if (( $(echo "$COVERAGE < $MINIMUM" | bc -l) )); then
            echo "Test coverage is below minimum: $COVERAGE% < $MINIMUM%"
            exit 1
          else
            echo "Coverage is $COVERAGE%, which meets the minimum requirement of $MINIMUM%"
          fi
        env:
          DISCORD_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
          
      - name: Run Credo
        run: mix credo --strict
        

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    environment: Builder
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5
        
      - name: Set up QEMU (for multi-platform builds)
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: |
            ${{ secrets.DOCKERHUB_USERNAME }}/soundbored:latest
            ${{ secrets.DOCKERHUB_USERNAME }}/soundbored:${{ github.sha }}
            ghcr.io/${{ github.repository }}/soundbored:latest
            ghcr.io/${{ github.repository }}/soundbored:${{ github.sha }}

  docs:
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - name: Set up Elixir
        uses: erlef/setup-beam@v1
        with:
          elixir-version: '1.19.x'
          otp-version: '27.x'

      - name: Restore dependencies cache
        uses: actions/cache@v4
        with:
          path: deps
          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
          restore-keys: ${{ runner.os }}-mix-

      - name: Install dependencies
        run: mix deps.get

      - name: Generate documentation
        run: mix docs

      - name: Deploy documentation to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_branch: gh-pages
          publish_dir: doc
          enable_jekyll: false


================================================
FILE: .gitignore
================================================
# The directory Mix will write compiled artifacts to.
/_build/

/reports/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Temporary files, for example, from tests.
/tmp/

# Ignore package tarball (built via "mix hex.build").
soundboard-*.tar

# Ignore assets that are produced by build tools.
/priv/static/assets/

# Ignore digested assets cache.
/priv/static/cache_manifest.json
/priv/static/favicon-*.ico
/priv/static/manifest-*.json
/priv/static/manifest-*.json.gz
/priv/static/manifest.json.gz
/priv/static/robots-*.txt
/priv/static/robots-*.txt.gz
/priv/static/robots.txt.gz
/priv/static/images/icon-*
/priv/static/images/*-*

# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

/priv/static/uploads/

.env

.DS_Store


CLAUDE.md

.serena

TODO

.desloppify/
.pi/

.env.local


================================================
FILE: .tool-versions
================================================
erlang 27.2
elixir 1.19.0-otp-27


================================================
FILE: AGENTS.md
================================================
# Repository Guidelines

## Project Structure & Module Organization
- Source: `lib/soundboard` (core), `lib/soundboard_web` (Phoenix web, LiveView, controllers, components).
- Frontend assets: `assets/js`, `assets/css`, `assets/tailwind.config.js`.
- Config and priv: `config/`, `priv/` (static, migrations, etc.).
- Tests: `test/` mirroring lib; helpers in `test/support`.

## Build, Test, and Development Commands
- Setup: `mix setup` — fetch deps, create/migrate DB, install/build assets.
- Run dev server: `mix phx.server` (or `iex -S mix phx.server`).
- Tests: `mix test` — includes DB setup via alias.
- Coverage: `mix coveralls` or `mix coveralls.html` (outputs to `cover/`).
- Lint/format: `mix credo --strict` and `mix format`.
- Assets prod build: `mix assets.deploy`.
- Docker local: `docker compose up` (env from `.env`).

## Coding Style & Naming Conventions
- Elixir style: 2‑space indent; run `mix format` before committing.
- Modules: `Soundboard.*` and `SoundboardWeb.*`; filenames snake_case.
- Functions/vars: snake_case; constants via module attributes.
- Components/LiveViews live under `lib/soundboard_web/{components,live}` with descriptive names (e.g., `favorites_live.ex`).

## Testing Guidelines
- Framework: ExUnit with helpers in `test/support` (`ConnCase`, `DataCase`).
- Naming: mirror module under `test/…/*_test.exs` (e.g., `stats_test.exs`).
- Run selective: `mix test test/soundboard/stats_test.exs:42`.
- Coverage: aim >90% for new code; add unit tests for contexts and LiveView interaction where feasible.

## Commit & Pull Request Guidelines
- Commits: imperative mood, concise (e.g., "Fix audio playback path"). Group related changes.
- PRs: clear description, linked issues, screenshots for UI changes, reproduction steps, and risk/rollback notes.
- Checks: run `mix precommit` before pushing; it covers compile warnings, unused deps, formatting, Credo, tests, and clone detection.

## Security & Configuration Tips
- Secrets via `.env` (see `.env.example`): Discord tokens, API token, `PHX_HOST`, `SCHEME`.
- Do not commit real secrets; prefer Docker env files in development and deployment.
- For production, keep secrets in `.env` and run the single compose stack; integrate your own reverse proxy/load balancer as needed.


================================================
FILE: Dockerfile
================================================
# syntax=docker/dockerfile:1
FROM elixir:1.19-alpine AS build

ARG MIX_ENV=prod

ENV MIX_ENV=$MIX_ENV \
    MIX_HOME=/app/.mix \
    HEX_HOME=/app/.hex \
    LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    LC_CTYPE=C.UTF-8

RUN apk add --no-cache \
    git \
    make \
    build-base \
    rust \
    cargo

WORKDIR /app
COPY --exclude=entrypoint.sh . .

# Install hex/rebar, get dependencies, and refresh EDA from latest main.
RUN mkdir -p /app/.mix /app/.hex && \
    mix local.hex --force && \
    mix local.rebar --force && \
    mix deps.get && \
    mix deps.update eda

RUN export SKIP_RUNTIME_CONFIG=1 && \
    mix assets.setup && \
    mix compile && \
    mix assets.deploy && \
    cd deps/eda/native/eda_dave && \
    cargo build --release && \
    mkdir -p /app/_build/${MIX_ENV}/lib/eda/priv/native && \
    cp target/release/libeda_dave.so /app/_build/${MIX_ENV}/lib/eda/priv/native/eda_dave.so

FROM elixir:1.19-alpine

ENV MIX_ENV=prod \
    MIX_HOME=/app/.mix \
    HEX_HOME=/app/.hex \
    HOME=/app \
    LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    LC_CTYPE=C.UTF-8

RUN apk add --no-cache \
    ffmpeg \
    git \
    libstdc++

WORKDIR /app
COPY --from=build /app .
RUN chmod -R a+rX /app/.mix /app/.hex

COPY entrypoint.sh /app
RUN chmod a+x /app/entrypoint.sh

VOLUME ["/app/priv/static/uploads"]
EXPOSE 4000
ENTRYPOINT ["/app/entrypoint.sh"]


================================================
FILE: README.md
================================================
# Soundbored
[![Coverage Status](https://coveralls.io/repos/github/christomitov/soundbored/badge.svg?branch=main)](https://coveralls.io/github/christomitov/soundbored?branch=main)
[![Build Status](https://github.com/christomitov/soundbored/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/christomitov/soundbored/actions)

Soundbored is an unlimited, no-cost, self-hosted soundboard for Discord. It allows you to play sounds in a voice channel.


[Hexdocs](https://christomitov.github.io/soundbored/)

<img width="1468" alt="Screenshot 2025-01-18 at 1 26 07 PM" src="https://github.com/user-attachments/assets/4a504100-5ef9-47bc-b406-35b67837e116" />

### CLI Companion
Install the cross-platform CLI with `npm i -g soundbored` for quick automation. Source: [christomitov/soundbored-cli](https://github.com/christomitov/soundbored-cli).

## Quickstart

1. Copy the sample environment and set the minimum values:
   ```bash
   cp .env.example .env
   # Required for local testing
   # DISCORD_TOKEN=...
   # DISCORD_CLIENT_ID=...
   # DISCORD_CLIENT_SECRET=...
   # PHX_HOST=localhost
   # SCHEME=http
   ```
2. Run the published container:
   ```bash
   docker run -d -p 4000:4000 --env-file ./.env christom/soundbored
   ```
3. Visit http://localhost:4000, invite the bot, and trigger your first sound.

> Create the bot in the [Discord Developer Portal](https://discord.com/developers/applications), enable **Presence**, **Server Members**, and **Message Content** intents, and grant Send Messages, Read History, View Channels, Connect, and Speak permissions when you invite it.

### Discord App Setup

1. In the Discord Developer Portal, open your application and go to **Bot** → enable **Presence**, **Server Members**, and **Message Content** intents.
2. Still in the portal, go to **OAuth2 → Redirects** and add every URL that will serve Soundbored to the **Redirects** list. For example:
   - `http://localhost:4000/auth/discord/callback` (local development)
   - `https://your.domain.com/auth/discord/callback` (production, replace with your domain)
   Discord requires the redirect in your app configuration to match exactly what the browser uses during login; otherwise, OAuth will fail.
3. Copy the **Client ID** and **Client Secret** from the same page—add them to your `.env` file as `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET`.
4. Use **OAuth2 → URL Generator** (scope `bot`) to produce the invite link with the permissions listed above.

## Local Development

```bash
mix setup        # Fetch deps, prepare DB, build assets
mix phx.server   # or iex -S mix phx.server
```

Useful commands:
- `mix test` – run the test suite (coverage via `mix coveralls`).
- `mix credo --strict` – linting.

`docker compose up` also works for a containerized local run; it respects the same `.env` configuration.

## Environment Variables

All available keys live in `.env.example`. Configure the ones that match your setup:

| Variable | Required | Purpose |
| --- | --- | --- |
| `DISCORD_TOKEN` | ✔ | Bot token used to play audio in voice channels. |
| `EDA_DAVE` | optional | Override for Discord E2EE voice negotiation in EDA. Default is enabled; set `false` only for troubleshooting. |
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | ✔ | OAuth credentials for Discord login. |
| `BASIC_AUTH_USERNAME` / `BASIC_AUTH_PASSWORD` | optional | Protect the browser UI with HTTP basic auth. API routes stay behind API token auth. |
| `SECRET_KEY_BASE` | ✔ | Signing/encryption secret; generate via `mix phx.gen.secret` or `openssl rand -base64 48`. Takes precedence over `SECRET_KEY_BASE_FILE`.|
| `SECRET_KEY_BASE_FILE` | optional | Path to file containing signing/encryption secret (e.g. for docker secrets). Preferred for security. |
| `PHX_HOST` | ✔ | Hostname the app advertises (`localhost` for local runs). |
| `SCHEME` | ✔ | `http` locally, `https` in production. |
| `AUTO_JOIN` | optional | Voice join mode. `play` (default) — bot joins when you play a sound. `presence` — bot follows users into channels. `false` — manual `!join` only. |
| `VOICE_IDLE_TIMEOUT_SECONDS` | optional | Seconds of inactivity before the bot auto-leaves. Defaults to `600` (10 min). Set to `0` to disable. In `play` mode: timer resets per sound, bot also leaves when the last user departs. In `false` mode: timer starts after the last user leaves. In `presence` mode: ignored. |
| `BIND_IP` | optional | IP address the HTTP server binds to. Defaults to `127.0.0.1`; set to `0.0.0.0` to bind all interfaces (e.g. Docker dev). |

## Deployment

The application is published to Docker Hub as `christom/soundbored`.

### Simple Docker Host
```bash
docker pull christom/soundbored:latest
docker run -d -p 4000:4000 --env-file ./.env christom/soundbored
```
If you place the container behind your own reverse proxy, set `PHX_HOST` and `SCHEME` in `.env` to match the external URL and terminate TLS in your proxy. No additional compose files are required.

## Usage

After inviting the bot to your server, join a voice channel and type `!join` to have the bot join the voice channel. Type `!leave` to have the bot leave. You can upload sounds to Soundbored and trigger them there and they will play in the voice channel.

The bot manages voice channels automatically in two ways:

The `AUTO_JOIN` variable controls three modes:

- **`play` (default)**: the bot joins automatically when you play a sound from the web UI or API. It leaves when the last user departs or after `VOICE_IDLE_TIMEOUT_SECONDS` seconds of no playback activity (default: 600 s / 10 min).
- **`presence`**: the bot proactively follows users into voice channels on join events, and leaves immediately when the last user departs. The idle timeout is ignored in this mode.
- **`false`**: fully manual — use `!join` / `!leave`. If `VOICE_IDLE_TIMEOUT_SECONDS` is set, the bot leaves automatically that many seconds after the last user departs.

## API

The API is used to trigger sounds from other applications. Create a personal API token in **Settings** after signing in, then send it as `Authorization: Bearer <USER_API_TOKEN>`.

Current API workflow supports:
- listing sounds
- uploading local files
- creating URL-backed sounds
- queueing playback for a specific sound
- stopping active playback

### Endpoints

#### List sounds
```bash
curl https://soundboardurl.com/api/sounds \
  -H "Authorization: Bearer <USER_API_TOKEN>"
```
Returns `200 OK` with `%{data: [...]}`.

#### Upload a local file
```bash
curl -X POST https://soundboardurl.com/api/sounds \
  -H "Authorization: Bearer <USER_API_TOKEN>" \
  -F "source_type=local" \
  -F "name=wow" \
  -F "file=@/path/to/wow.mp3" \
  -F "tags[]=meme" \
  -F "volume=90"
```
Returns `201 Created` with `%{data: sound}`.

#### Create a URL-backed sound
```bash
curl -X POST https://soundboardurl.com/api/sounds \
  -H "Authorization: Bearer <USER_API_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"source_type":"url","name":"wow","url":"https://example.com/wow.mp3","tags":["meme","reaction"],"volume":90}'
```
Returns `201 Created` with `%{data: sound}`.

#### Queue playback for a sound
```bash
curl -X POST https://soundboardurl.com/api/sounds/123/play \
  -H "Authorization: Bearer <USER_API_TOKEN>"
```
Returns `202 Accepted` with `%{data: %{status: "accepted", ...}}` because playback is queued asynchronously.

#### Stop active playback
```bash
curl -X POST https://soundboardurl.com/api/sounds/stop \
  -H "Authorization: Bearer <USER_API_TOKEN>"
```
Returns `202 Accepted` with `%{data: %{status: "accepted", ...}}` because the stop request is also asynchronous.

Errors use `%{error: message}` or `%{errors: changeset_errors}` depending on whether the failure is request-level or validation-level.

## Changelog

### v1.7.0 (2026-03-07)

#### ✨ New Features
- Switched the Discord voice/runtime integration over to EDA, bringing DAVE support for current Discord voice encryption negotiation.
- Expanded the authenticated API so external tools can list sounds, upload local files, create URL-backed sounds, queue playback, and stop playback with personal user tokens.
- Public URL handling is now centralized so Discord invite/auth links and API examples stay aligned with the configured host and scheme.

#### ⚙️ Improvements
- Audio playback startup is faster and more resilient, reducing common delay/glitch cases during sound playback.
- Voice runtime handling was split into smaller policy/command/presence modules, making Discord connection behavior easier to reason about and maintain.
- Upload and tag persistence flows were consolidated so the LiveView and API paths share the same domain logic.
- The app now boots in a degraded mode when optional voice runtime capabilities are unavailable instead of failing startup entirely.

#### 🧪 Tests & Quality
- Added coverage for command handling, runtime capability detection, public URL behavior, API auth, upload flows, and collaborative sound management rules.
- Clarified the intended collaboration model: any signed-in user can edit shared sound details, but only the original uploader can delete a sound.
- Removed stale dependencies and cleanup scaffolding while continuing the broader code-health refactor.

### v1.6.0 (2025-10-01)

#### ✨ New Features
- New consolidated `Settings` view replaces the standalone API tokens screen and keeps token creation, revocation, and inline API examples in one place.
- Stats dashboard adds a week picker, richer recent activity stream, and refreshed layout under the new name “Stats”.
- “Play Random” now respects whatever filters are active, pulling from the current search results or selected tags only.

#### ⚙️ Improvements
- Shared tag components and modal tweaks streamline sound management and reduce layout shifts.
- Navigation highlights the active page and keeps Settings aligned with the rest of the app.
- Mobile refinements across the main board and settings eliminate horizontal scrolling and polish button spacing.
- Basic Auth now quietly skips enforcement when credentials are not configured instead of blocking the UI.

#### 🧪 Tests & Quality
- Expanded LiveView coverage for the new Settings page, Stats interactions, and filtered random playback.
- Updated CI workflow and Dependabot configuration keep coverage and dependency checks automated.

#### 📦 Dependencies
- Bumped Phoenix stack and related dependencies, plus cleaned up mix configuration and docs to match the new release.

### v1.5.0 (2025-09-14)

#### ✨ New Features
- User-scoped API tokens with DB storage (generate/revoke in Settings > API Tokens).
- API requests authenticated via `Authorization: Bearer <token>` are attributed to the token’s user and increment stats accordingly.
- In-app API help with copy-to-clipboard curl commands that auto-fill your site URL and token.
- Added Settings link in the navbar for quick access.
- Released a new CLI for easier local and CI integrations.

#### ⚙️ Improvements
- Search bar: reduced debounce to 200ms and added inline spinner while searching.
- Recent Plays: fixed item “disappearing” by using stable DB ids and deterministic ordering; clicked items now bump to the top correctly.

#### 🧪 Tests & Quality
- Added tests for API token lifecycle, API auth with DB tokens, Basic Auth, and the Settings LiveView.
- Coverage improved to ~96% (via mix coveralls).

#### 🔁 Compatibility
- DB-backed personal API tokens are the supported authentication path for API access.

### v1.4.0 (2025-08-22)

#### 🐛 Bug Fixes
- Fixed sounds not playing due to Discord API changes
- Optimized audio playback for faster sound loading and playback

#### 🔧 Maintenance
- Updated all dependencies to latest versions

### v1.3.0 (2025-02-18)

#### ✨ New Features
- Added API to get and trigger sounds.
- Added "stop all sounds" button.
- Implemented auto leave and join voice channels.
- Sorting sounds alphabetically
- Added ability to disable basic auth (just comment out BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD in .env)

### v1.2.0 (2025-01-18)

#### ✨ New Features
- Added random sound button.
- Added ability to add and trigger sounds from a URL.
- Allow ability to click tags inside sound Cards for filtering.
- Show what user uploaded a sound in the sound Card.

#### 🐛 Bug Fixes
- Fixed bug where if you uploaded a sound and edited its name before uploading a file it would crash.
- Fixed bug where changing an uploaded sound name created a new sound in entry and didn't update the old.

### v1.1.0 (2025-01-12)

#### ✨ New Features
- Implemented join/leave sound notifications
- Added Discord avatar support for member profiles
- Added week selector functionality to statistics page

#### 🐛 Bug Fixes
- Fixed mobile menu navigation issues on statistics page
- Fixed statistics page not updating in realtime
- Fixed styling issues on stats page


================================================
FILE: assets/css/app.css
================================================
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

.flash-message {
  opacity: 0;
  transition: opacity 300ms ease-in-out;
}

.opacity-0 { opacity: 0; }
.opacity-100 { opacity: 1; }

:root {
  background-color: rgb(17 24 39);
  height: 100%;
}

html {
  @apply bg-gray-900;
  height: 100%;
  /* Hide scrollbar for Chrome, Safari and Opera */
  ::-webkit-scrollbar {
    display: none;
  }
  /* Hide scrollbar for IE, Edge and Firefox */
  -ms-overflow-style: none;  /* IE and Edge */
  scrollbar-width: none;  /* Firefox */
}

body {
  @apply bg-gray-900;
  min-height: 100%;
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
  /* Enables momentum scrolling on iOS */
  -webkit-overflow-scrolling: touch;
  /* Hide scrollbar while allowing scrolling */
  overflow-y: auto;
  ::-webkit-scrollbar {
    display: none;
  }
}

.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 1rem;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

/* Show inline search spinner while server processes phx-change */
.phx-change-loading .search-spinner { display: inline; }
.phx-change-loading .search-icon { display: none; }


================================================
FILE: assets/js/app.js
================================================
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"

// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
//     import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
//     import "some-package"
//

// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
const roundTo = (value, decimals = 4) => {
  const factor = Math.pow(10, decimals)
  return Math.round(value * factor) / factor
}

const MAX_VOLUME_PERCENT_DEFAULT = 150
const BOOST_CAP = 1.5

const getAudioContextCtor = () => window.AudioContext || window.webkitAudioContext || null

const parsePercent = (
  value,
  fallback = MAX_VOLUME_PERCENT_DEFAULT,
  maxPercent = MAX_VOLUME_PERCENT_DEFAULT
) => {
  const parseNumeric = (input) => {
    if (typeof input === "number" && Number.isFinite(input)) {
      return input
    }
    if (typeof input === "string") {
      const parsed = parseFloat(input.trim())
      if (!Number.isNaN(parsed)) {
        return parsed
      }
    }
    return null
  }

  const parsedValue = parseNumeric(value)
  const parsedFallback = parseNumeric(fallback)
  const base = parsedValue === null ? (parsedFallback === null ? maxPercent : parsedFallback) : parsedValue
  return clamp(Math.round(base), 0, maxPercent)
}

const percentToGain = (percent, maxPercent = MAX_VOLUME_PERCENT_DEFAULT) => {
  const clampedPercent = clamp(Math.round(percent), 0, maxPercent)
  if (clampedPercent <= 100) {
    return roundTo(clampedPercent / 100)
  }
  const boosted = 1 + (clampedPercent - 100) * 0.01
  return roundTo(Math.min(boosted, BOOST_CAP))
}

const setElementGain = (audio, gain) => {
  if (!audio) {
    return
  }

  const clampedGain = clamp(gain, 0, BOOST_CAP)
  const elementVolume = Math.min(clampedGain, 1)

  try {
    audio.volume = elementVolume
  } catch (_err) {
    audio.volume = 1
  }

  if (audio.__gainNode) {
    audio.__gainNode.gain.value = clampedGain > 1 ? clampedGain : 1
  }
}

let activeLocalPlayer = null

const stopActiveLocalPlayer = () => {
  if (activeLocalPlayer && typeof activeLocalPlayer.stopPlayback === "function") {
    activeLocalPlayer.stopPlayback()
  }
}

window.addEventListener("phx:stop-all-sounds", stopActiveLocalPlayer)

let Hooks = {}
Hooks.LocalPlayer = {
  mounted() {
    this.audio = null
    this.audioContext = null
    this.cleanup = null
    this.handleClick = this.handleClick.bind(this)
    this.el.addEventListener("click", this.handleClick)
  },
  updated() {
    if (this.audio && !this.audio.paused) {
      this.configureGain(this.readGain())
    }
  },
  destroyed() {
    this.el.removeEventListener("click", this.handleClick)
    this.stopPlayback()
  },
  readGain() {
    const raw = parseFloat(this.el.dataset.volume)
    return Number.isFinite(raw) ? clamp(raw, 0, BOOST_CAP) : 1
  },
  async handleClick(event) {
    event.preventDefault()
    event.stopPropagation()

    if (this.audio && !this.audio.paused) {
      this.stopPlayback()
      return
    }

    if (activeLocalPlayer && activeLocalPlayer !== this) {
      activeLocalPlayer.stopPlayback()
    }

    await this.startPlayback()
  },
  async startPlayback() {
    this.stopPlayback()

    const sourceType = this.el.dataset.sourceType
    const url = this.el.dataset.url
    const filename = this.el.dataset.filename

    const audio = new Audio()

    if (sourceType === "url" && url) {
      audio.src = url
    } else if (filename) {
      audio.src = `/uploads/${filename}`
    } else {
      return
    }

    audio.addEventListener("ended", () => this.stopPlayback())
    audio.addEventListener("error", () => this.stopPlayback())

    this.audio = audio

    await this.configureGain(this.readGain())

    try {
      await audio.play()
      this.setPlaying(true)
      activeLocalPlayer = this
    } catch (error) {
      console.error("Audio playback failed", error)
      this.stopPlayback()
    }
  },
  async configureGain(targetGain) {
    if (!this.audio) {
      return
    }

    this.releaseBoost()

    const normalized = clamp(targetGain, 0, BOOST_CAP)
    const ContextCtor = getAudioContextCtor()

    if (!ContextCtor || normalized <= 1) {
      setElementGain(this.audio, normalized)
      return
    }

    if (!this.audioContext) {
      this.audioContext = new ContextCtor()
    }

    if (this.audioContext.state === "suspended") {
      try {
        await this.audioContext.resume()
      } catch (_err) {}
    }

    try {
      const source = this.audioContext.createMediaElementSource(this.audio)
      const gainNode = this.audioContext.createGain()
      gainNode.gain.value = normalized
      source.connect(gainNode).connect(this.audioContext.destination)
      this.audio.__gainNode = gainNode
      setElementGain(this.audio, normalized)

      this.cleanup = () => {
        try {
          source.disconnect()
        } catch (_err) {}
        try {
          gainNode.disconnect()
        } catch (_err) {}
        if (this.audio && this.audio.__gainNode === gainNode) {
          delete this.audio.__gainNode
        }
      }
    } catch (error) {
      console.warn("Unable to apply playback boost", error)
      setElementGain(this.audio, Math.min(normalized, 1))
    }
  },
  releaseBoost() {
    if (typeof this.cleanup === "function") {
      try {
        this.cleanup()
      } catch (_err) {}
    }
    this.cleanup = null
    if (this.audio) {
      delete this.audio.__gainNode
    }
  },
  stopPlayback() {
    this.releaseBoost()
    if (this.audio) {
      try {
        this.audio.pause()
        this.audio.currentTime = 0
      } catch (_err) {}
      this.audio = null
    }
    this.setPlaying(false)
    if (activeLocalPlayer === this) {
      activeLocalPlayer = null
    }
  },
  setPlaying(isPlaying) {
    const playIcon = this.el.querySelector(".play-icon")
    const stopIcon = this.el.querySelector(".stop-icon")

    if (!playIcon || !stopIcon) {
      return
    }

    if (isPlaying) {
      playIcon.classList.add("hidden")
      stopIcon.classList.remove("hidden")
    } else {
      playIcon.classList.remove("hidden")
      stopIcon.classList.add("hidden")
    }
  }
}

Hooks.VolumeControl = {
  mounted() {
    this.previewAudio = null
    this.previewContext = null
    this.previewSource = null
    this.previewGain = null
    this.objectUrl = null
    this.lastFile = null
    this.pushTimer = null
    this.previewLabel = "Preview"

    this.handleSliderInput = this.handleSliderInput.bind(this)
    this.handlePreviewClick = this.handlePreviewClick.bind(this)

    this.syncDataset()
    this.bindElements()

    this.setPercent(this.initialPercent(), {emit: false})
  },
  updated() {
    const previousKind = this.previewKind
    const previousSrc = this.previewSrc

    this.syncDataset()
    this.bindElements()
    this.setPercent(this.initialPercent(), {emit: false})

    if (previousKind && previousKind !== this.previewKind) {
      this.stopPreview(true)
    } else if (previousSrc !== this.previewSrc && this.previewKind !== "local-upload") {
      this.stopPreview()
    }
  },
  destroyed() {
    if (this.slider) {
      this.slider.removeEventListener("input", this.handleSliderInput)
    }
    if (this.previewButton) {
      this.previewButton.removeEventListener("click", this.handlePreviewClick)
    }
    if (this.pushTimer) {
      clearTimeout(this.pushTimer)
      this.pushTimer = null
    }
    this.stopPreview(true)
    if (this.previewSource) {
      try {
        this.previewSource.disconnect()
      } catch (_err) {}
    }
    if (this.previewGain) {
      try {
        this.previewGain.disconnect()
      } catch (_err) {}
    }
    this.previewSource = null
    this.previewGain = null
    if (this.previewContext) {
      try {
        this.previewContext.close()
      } catch (_err) {}
      this.previewContext = null
    }
  },
  syncDataset() {
    const dataset = this.el.dataset
    const parsedMax = parseInt(dataset.maxPercent || "", 10)
    this.maxPercent =
      Number.isInteger(parsedMax) && parsedMax > 0 ? parsedMax : MAX_VOLUME_PERCENT_DEFAULT
    this.pushEventName = dataset.pushEvent || null
    this.volumeTarget = dataset.volumeTarget || null
    this.previewKind = dataset.previewKind || "existing"
    this.fileInputId = dataset.fileInputId || null
    this.urlInputId = dataset.urlInputId || null
    this.previewSrc = dataset.previewSrc || ""
  },
  bindElements() {
    const slider = this.el.querySelector("[data-role='volume-slider']")
    if (this.slider !== slider) {
      if (this.slider) {
        this.slider.removeEventListener("input", this.handleSliderInput)
      }
      this.slider = slider
      if (this.slider) {
        this.slider.addEventListener("input", this.handleSliderInput)
      }
    }

    const previewButton = this.el.querySelector("[data-role='volume-preview']")
    if (this.previewButton !== previewButton) {
      if (this.previewButton) {
        this.previewButton.removeEventListener("click", this.handlePreviewClick)
      }
      this.previewButton = previewButton
      if (this.previewButton) {
        this.previewButton.addEventListener("click", this.handlePreviewClick)
      }
    }

    if (this.previewButton) {
      this.previewLabel = this.previewButton.textContent?.trim() || this.previewLabel
    }

    this.hiddenInput = this.el.querySelector("[data-role='volume-hidden']")
    this.display = this.el.querySelector("[data-role='volume-display']")
  },
  initialPercent() {
    const hiddenValue = this.hiddenInput?.value
    const sliderValue = this.slider?.value
    return parsePercent(hiddenValue ?? sliderValue ?? this.maxPercent, this.maxPercent, this.maxPercent)
  },
  setPercent(percent, {emit = false} = {}) {
    const bounded = clamp(Math.round(percent), 0, this.maxPercent)
    if (this.slider && Number(this.slider.value) !== bounded) {
      this.slider.value = bounded
    }

    if (this.hiddenInput && Number(this.hiddenInput.value) !== bounded) {
      this.hiddenInput.value = bounded
    }

    if (this.display) {
      this.display.textContent = `${bounded}%`
    }

    if (emit) {
      this.queuePush(bounded)
    }

    this.updatePreviewGain(bounded)
  },
  async handleSliderInput(event) {
    const fallback = this.hiddenInput?.value ?? this.slider?.value ?? this.maxPercent
    const nextPercent = parsePercent(event.target.value, fallback, this.maxPercent)
    event.target.value = nextPercent
    this.setPercent(nextPercent, {emit: true})
  },
  queuePush(percent) {
    if (!this.pushEventName) {
      return
    }

    if (this.pushTimer) {
      clearTimeout(this.pushTimer)
    }

    this.pushTimer = setTimeout(() => {
      const payload = {volume: percent}
      if (this.volumeTarget) {
        payload.target = this.volumeTarget
      }
      this.pushEvent(this.pushEventName, payload)
      this.pushTimer = null
    }, 100)
  },
  async handlePreviewClick(event) {
    event.preventDefault()

    if (this.previewButton && this.previewButton.disabled) {
      return
    }

    if (this.previewAudio && !this.previewAudio.paused) {
      this.stopPreview()
      return
    }

    const src = this.getPreviewSource()
    if (!src) {
      return
    }

    if (!this.previewAudio) {
      this.previewAudio = new Audio()
      this.previewAudio.addEventListener("ended", () => this.stopPreview())
      this.previewAudio.addEventListener("error", () => this.stopPreview())
    }

    this.previewAudio.src = src

    const percent = parsePercent(
      this.hiddenInput?.value ?? this.slider?.value ?? this.maxPercent,
      this.maxPercent,
      this.maxPercent
    )
    const gain = percentToGain(percent, this.maxPercent)
    await this.ensurePreviewGraph(gain)
    this.applyPreviewGain(gain)

    try {
      await this.previewAudio.play()
      this.setPreviewState(true)
    } catch (error) {
      console.error("Preview playback failed", error)
      this.setPreviewState(false)
    }
  },
  async updatePreviewGain(percent) {
    const gain = percentToGain(percent, this.maxPercent)

    if (!this.previewAudio) {
      return
    }

    await this.ensurePreviewGraph(gain)
    this.applyPreviewGain(gain)
  },
  async ensurePreviewGraph(targetGain) {
    if (!this.previewAudio) {
      return
    }

    const needsBoost = targetGain > 1
    const ContextCtor = getAudioContextCtor()

    if (!needsBoost || !ContextCtor) {
      if (this.previewGain) {
        this.previewGain.gain.value = 1
      }
      return
    }

    if (!this.previewContext) {
      this.previewContext = new ContextCtor()
    }

    if (this.previewContext.state === "suspended") {
      try {
        await this.previewContext.resume()
      } catch (_err) {}
    }

    if (!this.previewSource) {
      try {
        this.previewSource = this.previewContext.createMediaElementSource(this.previewAudio)
      } catch (error) {
        console.warn("Preview gain setup failed", error)
        this.previewSource = null
        this.previewGain = null
        return
      }
    }

    if (!this.previewGain) {
      this.previewGain = this.previewContext.createGain()
      this.previewSource.connect(this.previewGain).connect(this.previewContext.destination)
    }
  },
  applyPreviewGain(targetGain) {
    if (!this.previewAudio) {
      return
    }

    const base = clamp(targetGain, 0, BOOST_CAP)
    const volume = Math.min(base, 1)

    try {
      this.previewAudio.volume = volume
    } catch (_err) {
      this.previewAudio.volume = 1
    }

    if (this.previewGain) {
      this.previewGain.gain.value = base > 1 ? base : 1
    }
  },
  getPreviewSource() {
    if (this.previewKind === "local-upload" && this.fileInputId) {
      const input = document.getElementById(this.fileInputId)
      const file = input && input.files && input.files[0]

      if (!file) {
        return null
      }

      if (this.lastFile !== file) {
        if (this.objectUrl) {
          URL.revokeObjectURL(this.objectUrl)
        }
        this.objectUrl = URL.createObjectURL(file)
        this.lastFile = file
      }

      return this.objectUrl
    }

    if (this.previewKind === "url") {
      if (this.urlInputId) {
        const urlInput = document.getElementById(this.urlInputId)
        const value =
          urlInput && typeof urlInput.value === "string" ? urlInput.value.trim() : ""
        if (value) {
          return value
        }
      }

      return this.previewSrc || null
    }

    return this.previewSrc || null
  },
  stopPreview(forceRevoke = false) {
    if (this.previewAudio) {
      try {
        this.previewAudio.pause()
        this.previewAudio.currentTime = 0
      } catch (_err) {}
      this.previewAudio.src = ""
    }
    this.setPreviewState(false)

    if (forceRevoke && this.objectUrl) {
      URL.revokeObjectURL(this.objectUrl)
      this.objectUrl = null
      this.lastFile = null
    }
  },
  setPreviewState(isPlaying) {
    if (!this.previewButton) {
      return
    }

    this.previewButton.textContent = isPlaying ? "Stop Preview" : this.previewLabel
    this.previewButton.dataset.previewState = isPlaying ? "playing" : "stopped"
  }
}

Hooks.CopyButton = {
  mounted() {
    this.handleClick = async (e) => {
      e.preventDefault()
      const original = this.el.textContent
      const text =
        this.el.dataset.copyText ||
        this.el.getAttribute("data-copy-text") ||
        (this.el.nextElementSibling ? this.el.nextElementSibling.innerText : "")
      try {
        if (navigator.clipboard && window.isSecureContext) {
          await navigator.clipboard.writeText(text)
        } else {
          // Fallback for insecure contexts
          const ta = document.createElement("textarea")
          ta.value = text
          ta.style.position = "fixed"
          ta.style.opacity = "0"
          document.body.appendChild(ta)
          ta.select()
          document.execCommand("copy")
          document.body.removeChild(ta)
        }
        this.el.textContent = "Copied!"
        this.el.classList.add("text-green-600")
        setTimeout(() => {
          this.el.textContent = original
          this.el.classList.remove("text-green-600")
        }, 1500)
      } catch (_err) {
        this.el.textContent = "Copy failed"
        this.el.classList.add("text-red-600")
        setTimeout(() => {
          this.el.textContent = original
          this.el.classList.remove("text-red-600")
        }, 1500)
      }
    }
    this.el.addEventListener("click", this.handleClick)
  },
  destroyed() {
    this.el.removeEventListener("click", this.handleClick)
  }
}

let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: Hooks
})

// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300))
window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide())

// connect if there are any LiveViews on the page
liveSocket.connect()

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

if (window.navigator.standalone) {
  document.documentElement.style.setProperty("--sat", "env(safe-area-inset-top)")
  document.documentElement.classList.add("standalone")
}


================================================
FILE: assets/js/hooks/local_player.js
================================================
const LocalPlayer = {
  currentAudio: null,
  currentButton: null,

  mounted() {
    this.el.addEventListener("click", () => {
      const filename = this.el.dataset.filename;
      
      // If clicking the same button that's currently playing
      if (LocalPlayer.currentButton === this.el && LocalPlayer.currentAudio) {
        // Stop the audio and reset the button
        LocalPlayer.currentAudio.pause();
        LocalPlayer.currentAudio.currentTime = 0;
        LocalPlayer.currentAudio = null;
        this.updateIcon(false);
        LocalPlayer.currentButton = null;
        return;
      }

      // If a different audio is playing, stop it and reset its button
      if (LocalPlayer.currentAudio && LocalPlayer.currentButton) {
        LocalPlayer.currentAudio.pause();
        LocalPlayer.currentAudio.currentTime = 0;
        LocalPlayer.currentButton.querySelector('svg').outerHTML = this.playIcon();
      }

      // Play the new audio
      LocalPlayer.currentAudio = new Audio(`/uploads/${filename}`);
      LocalPlayer.currentButton = this.el;
      LocalPlayer.currentAudio.play();
      this.updateIcon(true);

      // When audio ends, reset the button
      LocalPlayer.currentAudio.onended = () => {
        this.updateIcon(false);
        LocalPlayer.currentAudio = null;
        LocalPlayer.currentButton = null;
      };
    });
  },

  updateIcon(isPlaying) {
    this.el.querySelector('svg').outerHTML = isPlaying ? this.stopIcon() : this.playIcon();
  },

  playIcon() {
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
      <path d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
      <path fill-rule="evenodd" d="M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 010-1.113zM17.25 12a5.25 5.25 0 11-10.5 0 5.25 5.25 0 0110.5 0z" clip-rule="evenodd" />
    </svg>`;
  },

  stopIcon() {
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
      <path fill-rule="evenodd" d="M4.5 7.5a3 3 0 013-3h9a3 3 0 013 3v9a3 3 0 01-3 3h-9a3 3 0 01-3-3v-9z" clip-rule="evenodd" />
    </svg>`;
  }
}

export default LocalPlayer; 

================================================
FILE: assets/tailwind.config.js
================================================
// See the Tailwind configuration guide for advanced usage
// https://tailwindcss.com/docs/configuration

const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")

module.exports = {
  content: [
    "./js/**/*.js",
    "../lib/*_web/**/*.*ex"
  ],
  theme: {
    extend: {
      colors: {
        brand: "#FD4F00",
      },
      animation: {
        'fade-in': 'fadeIn 0.3s ease-in',
        'fade-out': 'fadeOut 0.3s ease-out',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '0.3' },
        },
        fadeOut: {
          '0%': { opacity: '0.3' },
          '100%': { opacity: '0' },
        },
      },
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
    // Allows prefixing tailwind classes with LiveView classes to add rules
    // only when LiveView classes are applied, for example:
    //
    //     <div class="phx-click-loading:animate-ping">
    //
    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),

    // Embeds Heroicons (https://heroicons.com) into your app.css bundle
    // See your `CoreComponents.icon/1` for more information.
    //
    plugin(function({matchComponents, theme}) {
      let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
      let values = {}
      let icons = [
        ["", "/24/outline"],
        ["-solid", "/24/solid"],
        ["-mini", "/20/solid"],
        ["-micro", "/16/solid"]
      ]
      icons.forEach(([suffix, dir]) => {
        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
          let name = path.basename(file, ".svg") + suffix
          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
        })
      })
      matchComponents({
        "hero": ({name, fullPath}) => {
          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
          let size = theme("spacing.6")
          if (name.endsWith("-mini")) {
            size = theme("spacing.5")
          } else if (name.endsWith("-micro")) {
            size = theme("spacing.4")
          }
          return {
            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
            "-webkit-mask": `var(--hero-${name})`,
            "mask": `var(--hero-${name})`,
            "mask-repeat": "no-repeat",
            "background-color": "currentColor",
            "vertical-align": "middle",
            "display": "inline-block",
            "width": size,
            "height": size
          }
        }
      }, {values})
    })
  ]
}


================================================
FILE: assets/vendor/topbar.js
================================================
/**
 * @license MIT
 * topbar 2.0.0, 2023-02-04
 * https://buunguyen.github.io/topbar
 * Copyright (c) 2021 Buu Nguyen
 */
(function (window, document) {
  "use strict";

  // https://gist.github.com/paulirish/1579671
  (function () {
    var lastTime = 0;
    var vendors = ["ms", "moz", "webkit", "o"];
    for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
      window.requestAnimationFrame =
        window[vendors[x] + "RequestAnimationFrame"];
      window.cancelAnimationFrame =
        window[vendors[x] + "CancelAnimationFrame"] ||
        window[vendors[x] + "CancelRequestAnimationFrame"];
    }
    if (!window.requestAnimationFrame)
      window.requestAnimationFrame = function (callback, element) {
        var currTime = new Date().getTime();
        var timeToCall = Math.max(0, 16 - (currTime - lastTime));
        var id = window.setTimeout(function () {
          callback(currTime + timeToCall);
        }, timeToCall);
        lastTime = currTime + timeToCall;
        return id;
      };
    if (!window.cancelAnimationFrame)
      window.cancelAnimationFrame = function (id) {
        clearTimeout(id);
      };
  })();

  var canvas,
    currentProgress,
    showing,
    progressTimerId = null,
    fadeTimerId = null,
    delayTimerId = null,
    addEvent = function (elem, type, handler) {
      if (elem.addEventListener) elem.addEventListener(type, handler, false);
      else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
      else elem["on" + type] = handler;
    },
    options = {
      autoRun: true,
      barThickness: 3,
      barColors: {
        0: "rgba(26,  188, 156, .9)",
        ".25": "rgba(52,  152, 219, .9)",
        ".50": "rgba(241, 196, 15,  .9)",
        ".75": "rgba(230, 126, 34,  .9)",
        "1.0": "rgba(211, 84,  0,   .9)",
      },
      shadowBlur: 10,
      shadowColor: "rgba(0,   0,   0,   .6)",
      className: null,
    },
    repaint = function () {
      canvas.width = window.innerWidth;
      canvas.height = options.barThickness * 5; // need space for shadow

      var ctx = canvas.getContext("2d");
      ctx.shadowBlur = options.shadowBlur;
      ctx.shadowColor = options.shadowColor;

      var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
      for (var stop in options.barColors)
        lineGradient.addColorStop(stop, options.barColors[stop]);
      ctx.lineWidth = options.barThickness;
      ctx.beginPath();
      ctx.moveTo(0, options.barThickness / 2);
      ctx.lineTo(
        Math.ceil(currentProgress * canvas.width),
        options.barThickness / 2
      );
      ctx.strokeStyle = lineGradient;
      ctx.stroke();
    },
    createCanvas = function () {
      canvas = document.createElement("canvas");
      var style = canvas.style;
      style.position = "fixed";
      style.top = style.left = style.right = style.margin = style.padding = 0;
      style.zIndex = 100001;
      style.display = "none";
      if (options.className) canvas.classList.add(options.className);
      document.body.appendChild(canvas);
      addEvent(window, "resize", repaint);
    },
    topbar = {
      config: function (opts) {
        for (var key in opts)
          if (options.hasOwnProperty(key)) options[key] = opts[key];
      },
      show: function (delay) {
        if (showing) return;
        if (delay) {
          if (delayTimerId) return;
          delayTimerId = setTimeout(() => topbar.show(), delay);
        } else  {
          showing = true;
          if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
          if (!canvas) createCanvas();
          canvas.style.opacity = 1;
          canvas.style.display = "block";
          topbar.progress(0);
          if (options.autoRun) {
            (function loop() {
              progressTimerId = window.requestAnimationFrame(loop);
              topbar.progress(
                "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
              );
            })();
          }
        }
      },
      progress: function (to) {
        if (typeof to === "undefined") return currentProgress;
        if (typeof to === "string") {
          to =
            (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
              ? currentProgress
              : 0) + parseFloat(to);
        }
        currentProgress = to > 1 ? 1 : to;
        repaint();
        return currentProgress;
      },
      hide: function () {
        clearTimeout(delayTimerId);
        delayTimerId = null;
        if (!showing) return;
        showing = false;
        if (progressTimerId != null) {
          window.cancelAnimationFrame(progressTimerId);
          progressTimerId = null;
        }
        (function loop() {
          if (topbar.progress("+.1") >= 1) {
            canvas.style.opacity -= 0.05;
            if (canvas.style.opacity <= 0.05) {
              canvas.style.display = "none";
              fadeTimerId = null;
              return;
            }
          }
          fadeTimerId = window.requestAnimationFrame(loop);
        })();
      },
    };

  if (typeof module === "object" && typeof module.exports === "object") {
    module.exports = topbar;
  } else if (typeof define === "function" && define.amd) {
    define(function () {
      return topbar;
    });
  } else {
    this.topbar = topbar;
  }
}.call(this, window, document));


================================================
FILE: config/config.exs
================================================
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.

# General application configuration
import Config

# config :soundboard,
#   ecto_repos: [Soundboard.Repo],
#   generators: [timestamp_type: :utc_datetime],
#   token: System.get_env("DISCORD_TOKEN")

# EDA config shared across environments.
# Prefix commands like `!join` require :message_content intent.
config :eda,
  intents: [:guilds, :guild_messages, :guild_voice_states, :message_content],
  consumer: Soundboard.Discord.Consumer,
  gateway_encoding: :etf,
  dave: true

# Configures the endpoint
config :soundboard, SoundboardWeb.Endpoint,
  url: [host: "localhost", port: 4000],
  adapter: Bandit.PhoenixAdapter,
  render_errors: [
    formats: [html: SoundboardWeb.ErrorHTML, json: SoundboardWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: Soundboard.PubSub,
  live_view: [signing_salt: "9gxiIiqP"]

# Configure esbuild (the version is required)
config :esbuild,
  version: "0.17.11",
  soundboard: [
    args:
      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

# Configure tailwind (the version is required)
config :tailwind,
  version: "3.4.3",
  soundboard: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

# Add MIME types for audio files
config :mime, :types, %{
  "audio/mpeg" => ["mp3"],
  "audio/ogg" => ["ogg"],
  "audio/wav" => ["wav"],
  "audio/x-m4a" => ["m4a"]
}

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

# Add this near the top of the file
config :soundboard,
  ecto_repos: [Soundboard.Repo]

# Add this somewhere in the file
config :soundboard, Soundboard.Repo,
  database: "priv/static/uploads/database.db",
  pool_size: 5

config :phoenix_live_view,
  flash_timeout: 3000

config :soundboard, SoundboardWeb.Presence, pubsub_server: Soundboard.PubSub

# Optional voice startup probe (disabled by default)
config :soundboard,
  voice_rtp_probe: false,
  voice_rtp_probe_timeout_ms: 6_000

# Add this with your other configs
config :ueberauth, Ueberauth,
  providers: [
    discord: {Ueberauth.Strategy.Discord, [default_scope: "identify"]}
  ]


================================================
FILE: config/dev.exs
================================================
import Config

config :soundboard, Soundboard.Repo,
  database: "database.db",
  adapter: Ecto.Adapters.SQLite3

generate_secret_key_base = fn ->
  Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
end

derive_secret_key_base = fn value ->
  :crypto.hash(:sha512, value)
  |> Base.encode64(padding: false)
end

secret_key_base =
  case System.get_env("SECRET_KEY_BASE") do
    value when is_binary(value) and byte_size(value) >= 64 ->
      value

    value when is_binary(value) ->
      derive_secret_key_base.(value)

    _ ->
      generate_secret_key_base.()
  end

config :soundboard, SoundboardWeb.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 4000],
  url: [host: "localhost", port: 4000, scheme: "http"],
  check_origin: false,
  code_reloader: true,
  debug_errors: true,
  secret_key_base: secret_key_base,
  watchers: [
    esbuild: {Esbuild, :install_and_run, [:soundboard, ~w(--sourcemap=inline --watch)]},
    tailwind: {Tailwind, :install_and_run, [:soundboard, ~w(--watch)]}
  ]

config :soundboard, SoundboardWeb.Endpoint,
  live_reload: [
    patterns: [
      ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
      ~r"priv/gettext/.*(po)$",
      ~r"lib/soundboard_web/(controllers|live|components)/.*(ex|heex)$"
    ]
  ]

config :soundboard, dev_routes: true
config :logger, :console, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime

config :phoenix_live_view,
  debug_heex_annotations: true,
  enable_expensive_runtime_checks: true

config :swoosh, :api_client, false
config :soundboard, env: :dev


================================================
FILE: config/prod.exs
================================================
import Config

# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :soundboard, SoundboardWeb.Endpoint,
  cache_static_manifest: "priv/static/cache_manifest.json"

config :soundboard, Soundboard.Repo,
  database: "/app/priv/static/uploads/soundboard_prod.db",
  pool_size: 5,
  stacktrace: true,
  show_sensitive_data_on_connection_error: true

# Ensure the uploads directory exists
config :soundboard,
  upload_directory: "/app/priv/static/uploads"

config :soundboard,
  env: :prod

# Configure logging for production - enable debug level for voice troubleshooting
config :logger, level: :debug


================================================
FILE: config/runtime.exs
================================================
import Config
import Dotenvy

env_dir_prefix = System.get_env("RELEASE_ROOT") || Path.expand(".")

source!([
  Path.absname(".env", env_dir_prefix),
  Path.absname(".#{config_env()}.env", env_dir_prefix),
  Path.absname(".#{config_env()}.overrides.env", env_dir_prefix),
  System.get_env()
])

# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.

# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
#     PHX_SERVER=true bin/soundboard start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if env!("PHX_SERVER", :boolean, false) do
  config :soundboard, SoundboardWeb.Endpoint, server: true
end

if config_env() == :dev do
  host = env!("PHX_HOST", :string!, "localhost:4000")
  scheme = env!("SCHEME", :string!, "http")
  port = env!("PORT", :integer, 4000)
  callback_url = "#{scheme}://#{host}/auth/discord/callback"
  discord_token = env!("DISCORD_TOKEN", :string!, nil)
  client_id = env!("DISCORD_CLIENT_ID", :string!, nil)
  client_secret = env!("DISCORD_CLIENT_SECRET", :string!, nil)
  eda_dave = env!("EDA_DAVE", :boolean, true)
  voice_rtp_probe = env!("VOICE_RTP_PROBE", :boolean, false)
  voice_rtp_probe_timeout_ms = env!("VOICE_RTP_PROBE_TIMEOUT_MS", :integer, 6_000)

  secret_key_base =
    case env!("SECRET_KEY_BASE", :string!, nil) do
      value when is_binary(value) and byte_size(value) >= 64 ->
        value

      value when is_binary(value) ->
        :crypto.hash(:sha512, value)
        |> Base.encode64(padding: false)

      _ ->
        nil
    end

  bind_ip =
    case env!("BIND_IP", :string!, "127.0.0.1")
         |> String.to_charlist()
         |> :inet.parse_address() do
      {:ok, ip_tuple} -> ip_tuple
      _ -> {127, 0, 0, 1}
    end

  endpoint_overrides = [
    url: [host: host, port: port, scheme: scheme],
    http: [ip: bind_ip, port: port]
  ]

  endpoint_overrides =
    if is_binary(secret_key_base) do
      Keyword.put(endpoint_overrides, :secret_key_base, secret_key_base)
    else
      endpoint_overrides
    end

  config :soundboard, SoundboardWeb.Endpoint, endpoint_overrides

  config :ueberauth, Ueberauth.Strategy.Discord.OAuth,
    client_id: client_id,
    client_secret: client_secret,
    redirect_uri: callback_url

  ffmpeg_available = not is_nil(System.find_executable("ffmpeg"))

  unless ffmpeg_available do
    IO.warn(
      "ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed."
    )
  end

  required_guild_id = env!("DISCORD_REQUIRED_GUILD_ID", :string, nil)

  required_role_ids =
    "DISCORD_REQUIRED_ROLE_IDS"
    |> env!(:string, "")
    |> String.split(",", trim: true)

  role_recheck_interval_seconds = env!("DISCORD_ROLE_RECHECK_INTERVAL_SECONDS", :integer, 900)

  config :soundboard,
    discord_token: discord_token,
    voice_rtp_probe: voice_rtp_probe,
    voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,
    ffmpeg_available: ffmpeg_available,
    required_guild_id: required_guild_id,
    required_role_ids: required_role_ids,
    role_recheck_interval_seconds: role_recheck_interval_seconds

  config :eda,
    token: discord_token,
    dave: eda_dave
end

# Allow build tooling to opt-out to avoid requiring secrets during image builds.
if config_env() == :prod and is_nil(env!("SKIP_RUNTIME_CONFIG", :string, nil)) do
  port = env!("PORT", :integer, 4000)

  # Replace the database_url section with SQLite configuration
  database_path = Path.join(:code.priv_dir(:soundboard), "static/uploads/soundboard_prod.db")

  config :soundboard, Soundboard.Repo,
    database: database_path,
    adapter: Ecto.Adapters.SQLite3,
    pool_size: env!("POOL_SIZE", :integer, 10)

  # The secret key base is used to sign/encrypt cookies and other secrets.
  secret_key_base =
    case env!("SECRET_KEY_BASE", :string!, nil) do
      value when is_binary(value) ->
        value

      _ ->
        case env!("SECRET_KEY_BASE_FILE", :string!, nil) do
          file when is_binary(file) ->
            case File.read(file) do
              {:ok, key} ->
                String.trim(key)

              {:error, reason} ->
                raise """
                could not read SECRET_KEY_BASE_FILE (#{file}): #{inspect(reason)}
                """
            end

          _ ->
            raise """
            environment variable SECRET_KEY_BASE is missing.
            Provide it via your environment (recommended) or set SECRET_KEY_BASE_FILE to a file path containing the key.
            Generate one with: mix phx.gen.secret OR openssl rand -base64 48
            """
        end
    end

  host = env!("PHX_HOST", :string!)
  scheme = env!("SCHEME", :string!, "https")
  callback_url = "#{scheme}://#{host}/auth/discord/callback"

  # Configure endpoint first
  config :soundboard, SoundboardWeb.Endpoint,
    # In prod, PHX_HOST represents the externally visible host. Do not append
    # the app's internal listen port unless the host itself already includes one.
    url: [
      scheme: scheme,
      host: host,
      port: nil
    ],
    http: [
      ip: {0, 0, 0, 0},
      port: port
    ],
    static_url: [
      host: host,
      port: nil
    ],
    check_origin: false,
    force_ssl: scheme == "https",
    secret_key_base: secret_key_base,
    session: [
      store: :cookie,
      key: "_soundboard_key",
      signing_salt: secret_key_base
    ]

  # Configure Ueberauth
  config :ueberauth, Ueberauth,
    providers: [
      discord: {Ueberauth.Strategy.Discord, [default_scope: "identify"]}
    ]

  # Configure Discord OAuth
  config :ueberauth, Ueberauth.Strategy.Discord.OAuth,
    client_id: env!("DISCORD_CLIENT_ID", :string!),
    client_secret: env!("DISCORD_CLIENT_SECRET", :string!),
    redirect_uri: callback_url

  # Configure Discord bot token
  discord_token = env!("DISCORD_TOKEN", :string!)

  # Store token for application use (bot will fetch it from here)
  voice_rtp_probe = env!("VOICE_RTP_PROBE", :boolean, false)
  voice_rtp_probe_timeout_ms = env!("VOICE_RTP_PROBE_TIMEOUT_MS", :integer, 6_000)
  eda_dave = env!("EDA_DAVE", :boolean, true)

  ffmpeg_available = not is_nil(System.find_executable("ffmpeg"))

  unless ffmpeg_available do
    IO.warn(
      "ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed."
    )
  end

  required_guild_id = env!("DISCORD_REQUIRED_GUILD_ID", :string, nil)

  required_role_ids =
    "DISCORD_REQUIRED_ROLE_IDS"
    |> env!(:string, "")
    |> String.split(",", trim: true)

  role_recheck_interval_seconds = env!("DISCORD_ROLE_RECHECK_INTERVAL_SECONDS", :integer, 900)

  config :soundboard,
    discord_token: discord_token,
    voice_rtp_probe: voice_rtp_probe,
    voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,
    ffmpeg_available: ffmpeg_available,
    required_guild_id: required_guild_id,
    required_role_ids: required_role_ids,
    role_recheck_interval_seconds: role_recheck_interval_seconds

  config :eda,
    token: discord_token,
    dave: eda_dave

  # Configure logger for production
  config :logger,
    # Set minimum log level to debug to see IO.puts
    level: :debug,
    compile_time_purge_matching: [
      # Don't purge debug logs
      [level_lower_than: :debug]
    ]

  config :logger, :console,
    format: "$time $metadata[$level] $message\n",
    metadata: [:request_id, :error],
    # Enable colors for better visibility
    colors: [enabled: true]

  # Keep stacktraces in production for better error reporting
  config :phoenix,
    stacktrace_depth: 20,
    plug_init_mode: :runtime

  config :soundboard, :env, :prod
end


================================================
FILE: config/test.exs
================================================
import Config

# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :soundboard, Soundboard.Repo,
  adapter: Ecto.Adapters.SQLite3,
  database: Path.expand("../soundboard_test.db", Path.dirname(__ENV__.file)),
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: 1,
  busy_timeout: 5000,
  journal_mode: :wal

# We don't run a server during test. If one is required,
# you can enable the server option below.
generate_secret_key_base = fn ->
  Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
end

derive_secret_key_base = fn value ->
  :crypto.hash(:sha512, value)
  |> Base.encode64(padding: false)
end

secret_key_base =
  case System.get_env("SECRET_KEY_BASE_TEST") do
    value when is_binary(value) and byte_size(value) >= 64 ->
      value

    value when is_binary(value) ->
      derive_secret_key_base.(value)

    _ ->
      generate_secret_key_base.()
  end

config :soundboard, SoundboardWeb.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 4002],
  secret_key_base: secret_key_base,
  server: false

# Print only warnings and errors during test
config :logger, level: :warning
# Configure the console backend
config :logger, :console,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id, :file, :line]

# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
  enable_expensive_runtime_checks: true

config :soundboard, :sql_sandbox, true

config :soundboard, env: :test

config :soundboard, Soundboard.AudioPlayer, voice_maintenance_enabled: false

config :soundboard, Soundboard.PubSub,
  adapter: Phoenix.PubSub.PG2,
  name: Soundboard.PubSub

config :eda,
  token: nil,
  consumer: nil,
  dave: false


================================================
FILE: coveralls.json
================================================
{
  "skip_files": [
    "lib/soundboard_web/presence.ex",
    "lib/soundboard_web/telemetry.ex",
    "lib/soundboard_web/gettext.ex",
    "lib/soundboard_web/endpoint.ex",
    "lib/soundboard_web/router.ex",
    "lib/soundboard_web/components/",
    "lib/soundboard_web/live/",
    "lib/soundboard_web/audio_player.ex",
    "lib/soundboard_web/discord_handler.ex",
    "lib/soundboard_web/controllers/upload_controller.ex",
    "lib/soundboard_web/controllers/error_html.ex",
    "lib/soundboard_web/controllers/error_json.ex",
    "lib/soundboard_web/eda_consumer.ex",
    "lib/soundboard/discord/guild_cache.ex",
    "lib/soundboard/discord/message.ex",
    "lib/soundboard/discord/self.ex",
    "lib/soundboard/discord/voice.ex",
    "lib/soundboard/audio_player.ex",
    "lib/soundboard/audio_player/playback_engine.ex",
    "lib/soundboard/discord/handler/voice_runtime.ex",
    "lib/soundboard/release.ex",
    "lib/soundboard/repo.ex",
    "lib/soundboard/application.ex",
    "lib/soundboard_web.ex",
    "test"
  ]
}


================================================
FILE: docker-compose.yml
================================================
services:
  soundbored:
    image: christom/soundbored:latest
    ports:
      - "127.0.0.1:4000:4000"
    env_file: .env
    volumes:
      - soundbored_data:/app/priv/static/uploads
    # the below can be added for improved security
      - type: tmpfs
        target: /tmp/mix_pubsub
        tmpfs:
          mode: 0777
    user: 9999:9999 # make sure this user has permissions on the soundbored_data volume
    read_only: true
    environment:
      TMPDIR: /tmp/mix_pubsub
      SECRET_KEY_BASE_FILE: /run/secrets/soundbored_secret_key_base
    secrets:
      - soundbored_secret_key_base

volumes:
  soundbored_data:

secrets:
  soundbored_secret_key_base:
    # this file should contain securely-generated random data (see README.md)
    # and only be readable by root or the user running the docker daemon
    file: ./secret_key_base.txt


================================================
FILE: docs/plans/discord-role-gated-access.md
================================================
# Discord Role-Gated Access Implementation Plan

**Status:** Complete
**Spec:** `docs/specs/discord-role-gated-access.md`

**Goal:** Restrict web access to Discord users who hold a configured role in a configured guild, with verification at login and periodic re-checks.

---

## Tasks

### Task 1: Config plumbing ✓
Added `DISCORD_REQUIRED_GUILD_ID`, `DISCORD_REQUIRED_ROLE_IDS`, and `DISCORD_ROLE_RECHECK_INTERVAL_SECONDS` to `config/runtime.exs` (both `:dev` and `:prod` blocks). Updated `.env.example`.

### Task 2: Soundboard.Discord.RoleChecker ✓
New module at `lib/soundboard/discord/role_checker.ex`. Exposes `feature_enabled?/0` and `authorized?/1`. Wraps `EDA.API.Member.get/2`, fails closed on any error.
Tests: `test/soundboard/discord/role_checker_test.exs` (8 tests).

### Task 3: Login-time role check in AuthController ✓
`AuthController.callback/2` calls `RoleChecker.authorized?/1` before `find_or_create_user`. Stores `:roles_verified_at` in session on success. No DB write on failure.
Tests: `test/soundboard_web/controllers/auth_controller_test.exs` (updated).

### Task 4: SoundboardWeb.Plugs.RoleCheck ✓
New plug at `lib/soundboard_web/plugs/role_check.ex`. Re-verifies role membership when session timestamp is missing or stale. Clears session and halts on unauthorized.
Tests: `test/soundboard_web/plugs/role_check_test.exs` (6 tests).

### Task 5: Wire plug into router ✓
Added `:require_role_check` pipeline to `lib/soundboard_web/router.ex`, inserted into both protected scopes after `:ensure_authenticated_user`.


================================================
FILE: docs/plans/discord-role-gated-access.md.tasks.json
================================================
{
  "planPath": "docs/plans/discord-role-gated-access.md",
  "tasks": [
    {
      "id": 2,
      "subject": "Task 1: Add config plumbing for required guild/role env vars",
      "status": "completed",
      "description": "**Goal:** New env vars are read by runtime.exs and stored under :soundboard app config keys.\n\n**Files:** config/runtime.exs, .env.example\n\n**Verify:** mix compile --warnings-as-errors\n\n```json:metadata\n{\"files\": [\"config/runtime.exs\", \".env.example\"], \"verifyCommand\": \"mix compile --warnings-as-errors\", \"acceptanceCriteria\": [\"required_guild_id defaults nil\", \"required_role_ids defaults []\", \"role_recheck_interval_seconds defaults 900\"]}\n```"
    },
    {
      "id": 3,
      "subject": "Task 2: Implement Soundboard.Discord.RoleChecker with TDD",
      "status": "completed",
      "blockedBy": [2],
      "description": "**Goal:** New module wraps EDA.API.Member.get/2 with feature_enabled?/0 and authorized?/1.\n\n**Files:** lib/soundboard/discord/role_checker.ex, test/soundboard/discord/role_checker_test.exs\n\n**Verify:** mix test test/soundboard/discord/role_checker_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard/discord/role_checker.ex\", \"test/soundboard/discord/role_checker_test.exs\"], \"verifyCommand\": \"mix test test/soundboard/discord/role_checker_test.exs\", \"acceptanceCriteria\": [\"feature_enabled? correct\", \"authorized? short-circuits when disabled\", \"matches any required role\", \"false on error\"]}\n```"
    },
    {
      "id": 4,
      "subject": "Task 3: Add login-time role check to AuthController",
      "status": "completed",
      "blockedBy": [3],
      "description": "**Goal:** OAuth callback verifies role membership before creating local user. Stores :user_id and :roles_verified_at on success.\n\n**Files:** lib/soundboard_web/controllers/auth_controller.ex, test/soundboard_web/controllers/auth_controller_test.exs\n\n**Verify:** mix test test/soundboard_web/controllers/auth_controller_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/controllers/auth_controller.ex\", \"test/soundboard_web/controllers/auth_controller_test.exs\"], \"verifyCommand\": \"mix test test/soundboard_web/controllers/auth_controller_test.exs\", \"acceptanceCriteria\": [\"authorized branch sets both session keys\", \"unauthorized creates no user record\", \"existing tests pass\"]}\n```"
    },
    {
      "id": 5,
      "subject": "Task 4: Implement SoundboardWeb.Plugs.RoleCheck with TDD",
      "status": "completed",
      "blockedBy": [3],
      "description": "**Goal:** Plug runs after ensure_authenticated_user. Re-verifies via RoleChecker when timestamp is stale/missing. Halts and clears session on failure.\n\n**Files:** lib/soundboard_web/plugs/role_check.ex, test/soundboard_web/plugs/role_check_test.exs\n\n**Verify:** mix test test/soundboard_web/plugs/role_check_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/plugs/role_check.ex\", \"test/soundboard_web/plugs/role_check_test.exs\"], \"verifyCommand\": \"mix test test/soundboard_web/plugs/role_check_test.exs\", \"acceptanceCriteria\": [\"pass through cases skip API\", \"stale/missing triggers re-check\", \"unauthorized halts and clears\"]}\n```"
    },
    {
      "id": 6,
      "subject": "Task 5: Wire RoleCheck plug into protected pipeline",
      "status": "completed",
      "blockedBy": [5],
      "description": "**Goal:** Both protected scopes invoke the plug. Full precommit passes.\n\n**Files:** lib/soundboard_web/router.ex\n\n**Verify:** mix precommit\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/router.ex\"], \"verifyCommand\": \"mix precommit\", \"acceptanceCriteria\": [\"pipeline defined\", \"both scopes wired\", \"full precommit passes\"]}\n```"
    }
  ],
  "lastUpdated": "2026-04-27T17:11:00Z"
}


================================================
FILE: docs/specs/discord-role-gated-access.md
================================================
# Spec: Discord Role-Gated Access

**Status:** Implemented
**Date:** 2026-04-27

---

## Summary

Restrict web access to Discord users who are members of a configured guild **and** hold at least one configured role. Verification happens at OAuth login and is re-checked periodically per session. When unconfigured, behavior is unchanged — anyone who completes Discord OAuth gets in.

---

## Configuration

Three env vars parsed in `config/runtime.exs`:

| Env Var | Type | Default | Description |
|---|---|---|---|
| `DISCORD_REQUIRED_GUILD_ID` | string | `nil` | Guild snowflake ID |
| `DISCORD_REQUIRED_ROLE_IDS` | comma-separated strings | `""` | Role snowflake IDs; user must hold at least one |
| `DISCORD_ROLE_RECHECK_INTERVAL_SECONDS` | integer | `900` | Seconds between role re-checks per session |

Feature is **enabled** only when both `DISCORD_REQUIRED_GUILD_ID` is set and `DISCORD_REQUIRED_ROLE_IDS` is non-empty. Either unset → feature disabled, no role checks performed.

---

## Behavior

| Scenario | Result |
|---|---|
| Feature not configured | Open access — current behavior preserved |
| User authenticates, has a required role | Logged in normally |
| User authenticates, lacks required role | No user record created; redirected to `/` with `"Error signing in"` flash |
| Logged-in user loses required role | At next re-check, session cleared, redirected to `/` |
| Discord API call fails | Fails closed — user denied |

The flash message is intentionally generic to avoid leaking why access was denied.

---

## Architecture

**`Soundboard.Discord.RoleChecker`** (`lib/soundboard/discord/role_checker.ex`)
Wraps `EDA.API.Member.get/2`. `feature_enabled?/0` checks config; `authorized?/1` short-circuits to `true` when disabled, otherwise checks guild role membership. Fails closed on any error or unexpected API response.

**`SoundboardWeb.AuthController`**
The OAuth callback calls `RoleChecker.authorized?/1` before `find_or_create_user`. On success, stores `:user_id` and `:roles_verified_at` in session. On failure, no DB write, generic flash.

**`SoundboardWeb.Plugs.RoleCheck`** (`lib/soundboard_web/plugs/role_check.ex`)
Runs after `ensure_authenticated_user`. Pass-through when: no `current_user`, feature disabled, or `:roles_verified_at` is fresh. Otherwise re-checks via `RoleChecker.authorized?/1` — updates timestamp on success, clears session and halts on failure.

**Router:** New `:require_role_check` pipeline wired into both protected scopes (`/` and `/uploads`) after `:ensure_authenticated_user`.

---

## Out of Scope

- Per-LiveView/per-action authorization
- Multi-guild support
- Re-checking on WebSocket lifecycle (LiveView mount)
- Dedicated denial page


================================================
FILE: docs/specs/voice-auto-join-idle-leave.md
================================================
# Spec: Voice Channel Auto-Join on Playback & Idle Auto-Leave

**Status:** Implemented  
**Date:** 2026-04-29  
**Author:** Justin Hart

---

## Summary

Three related quality-of-life improvements to bot voice channel management, unified under a single `AUTO_JOIN` mode enum:

1. **Auto-join on play** (`play` mode): when a user triggers sound playback from the web UI or API and the bot is not in any voice channel, the bot automatically joins the user's current Discord voice channel before playing.
2. **Idle auto-leave**: after a configurable period of inactivity (default: 600 seconds), the bot leaves the voice channel. Behavior varies by mode.
3. **`AUTO_JOIN` mode enum**: replaces the old boolean flag with a three-value enum (`play`, `presence`, `false`) that unifies join and leave behavior into one setting.

---

## Motivation

Previously, users had to manually type `!join` in Discord before playing sounds from the web UI. This broke the flow: open the soundboard tab, click a sound, nothing happens, switch to Discord, type `!join`, switch back, click again. The bot also had no way to clean up a lingering voice session after everyone drifted away from a channel.

The original implementation added auto-join on play and a global idle timeout. A follow-up redesign replaced the boolean `AUTO_JOIN` flag with a proper mode enum, aligning join behavior, leave behavior, and idle timeout semantics into a coherent model.

---

## User-Facing Behavior

### `AUTO_JOIN` Modes

| Mode | How bot joins | How bot leaves |
|---|---|---|
| `play` (default) | Joins when a sound is played from the web UI or API | Leaves when last user departs **or** after `VOICE_IDLE_TIMEOUT_SECONDS` of no audio (whichever first). If timeout ≤ 0, only leaves when last user departs. |
| `presence` | Follows users into channels on voice-state updates (existing behavior) | Leaves immediately when last user departs. Idle timeout is **ignored**. |
| `false` | Manual `!join` only | If timeout > 0: leaves after `VOICE_IDLE_TIMEOUT_SECONDS` of being alone (timer starts when last user departs, cancels if a user rejoins). If timeout ≤ 0: never auto-leaves. |

The old `AUTO_JOIN=true` maps to `presence`; `AUTO_JOIN=false` (explicit) maps to `false`; unset now defaults to `play`.

### Auto-Join on Play (in `play` mode)

| Situation | Behavior |
|---|---|
| Bot has no voice channel, user clicks a sound | Bot joins the user's current voice channel and plays the sound |
| User is not in any voice channel | Error: "Bot is not connected to a voice channel. Use !join in Discord first." |
| Actor is `System` (join/leave sounds) | Same error (no Discord identity to look up) |
| Bot already in a channel | Plays normally, unchanged |
| Mode is `presence` or `false` | Error (no auto-join, must use `!join`) |

### Idle Timeout Semantics by Mode

| Event | `play` mode | `presence` mode | `false` mode |
|---|---|---|---|
| Bot joins a channel | Timer starts (if timeout > 0) | No timer | No timer |
| `play_sound` cast | Timer resets | No effect | No effect |
| Last user leaves | Leave immediately | Leave immediately | Start idle timer (if timeout > 0) |
| User rejoins (bot alone) | N/A | N/A | Cancel idle timer |
| Idle timer fires | Leave immediately | Never fires | Leave immediately |
| Bot leaves (any reason) | Timer cancelled | N/A | Timer cancelled |

---

## Architecture & Design

### `AUTO_JOIN` Enum

`AutoJoinPolicy.mode/0` now returns `:presence | :play | false` (the boolean `false`, not an atom, per Elixir convention). Parsing rules:

| `AUTO_JOIN` value | Mode |
|---|---|
| not set | `:play` |
| `play` | `:play` |
| `presence`, `true`, `1`, `yes` | `:presence` |
| `false`, `0`, `no`, any other | `false` |

### Auto-Join Flow (play mode only)

```
Web UI / API → AudioPlayer.play_sound(name, %User{discord_id: ...})
  │
  ▼  (voice_channel is nil AND mode == :play)
AudioPlayer.handle_cast({:play_sound, ...}, %{voice_channel: nil})
  │
  ├─ extract discord_id from actor
  │
  ▼
VoicePresence.find_user_voice_channel(discord_id)
  │  searches all cached guilds via GuildCache
  │
  ├─ {:ok, {guild_id, channel_id}} ──► Voice.join_channel(guild_id, channel_id)
  │                                    update state.voice_channel
  │                                    schedule idle timer (if timeout > 0)
  │                                    proceed with playback
  │
  └─ :not_found ──► Notifier.error("Bot is not connected... Use !join in Discord first.")
```

The join happens directly inside the `handle_cast` body — state is updated synchronously, and playback proceeds in the same message handler. This avoids any circular-cast problem: `VoiceCommands.join_voice_channel` normally calls `AudioPlayer.set_voice_channel` as a success callback, which would deadlock if called from inside `AudioPlayer`. By calling `Voice.join_channel/2` directly and updating state ourselves, we avoid that dependency entirely.

### Last-User-Left Routing

`VoiceRuntime.handle_disconnect` now runs for **all** modes (previously short-circuited for `:disabled`). When the bot is confirmed alone, `bot_alone_action/1` dispatches by mode:

```
bot_alone_action(guild_id)
  :presence  ──► VoiceCommands.leave_voice_channel(guild_id)  (existing path)
  :play      ──► AudioPlayer.last_user_left(guild_id)         (leave immediately)
  false      ──► AudioPlayer.last_user_left(guild_id)         (start idle timer)
```

`AudioPlayer.last_user_left/1` is the new single entry point for non-presence leave events. It:
- `:play` / `:presence`: calls `Voice.leave_channel` directly, clears state.
- `false`: calls `reset_idle_timeout` — starts the idle timer if `VOICE_IDLE_TIMEOUT_SECONDS > 0`, otherwise no-ops (bot stays indefinitely).

### User-Rejoin Cancel (false mode only)

`VoiceRuntime.handle_connect` gains a `false`-mode clause: when a non-bot user joins the bot's current channel, it calls `AudioPlayer.user_joined_channel/1`, which cancels the idle timer. This prevents the bot from leaving when a user briefly steps out and returns.

### Idle Timeout State Machine

```
AudioPlayer state: idle_timeout_ref = {timer_ref, token} | nil

play mode:
  set_voice_channel({guild, chan}) → cancel old timer → schedule new timer
  play_sound cast                 → cancel old timer → schedule new timer (reset)
  set_voice_channel(nil, nil)     → cancel timer
  last_user_left                  → leave immediately, cancel timer
  {:idle_timeout, token}          → leave if token matches, else ignore (stale)

false mode:
  last_user_left                  → schedule timer (if timeout > 0)
  user_joined_channel             → cancel timer
  {:idle_timeout, token}          → leave if token matches, else ignore (stale)

presence mode:
  (no timer ever scheduled)
```

The `{ref, token}` pair guards against a race condition: if `Process.cancel_timer` returns `false` (the message already fired and is in the mailbox), the stale message arrives after a new timer is scheduled. The token mismatch causes it to be silently dropped rather than triggering a spurious leave.

### `IdleTimeoutPolicy` — Disabled State

`timeout_ms/0` now returns `nil` when `VOICE_IDLE_TIMEOUT_SECONDS <= 0` (previously always returned a positive integer). Callers treat `nil` as "disabled" and skip scheduling. This is how `false` mode achieves "never auto-leave" behavior.

### Direct `Voice.leave_channel` vs `VoiceCommands.leave_voice_channel`

`VoiceCommands.leave_voice_channel` would introduce a circular module dependency:
- `VoiceCommands` already calls `AudioPlayer.set_voice_channel` (compile-time dep)
- `AudioPlayer` calling `VoiceCommands` would close the cycle

`AudioPlayer` calls `Voice.leave_channel/1` directly and updates its own state. This is consistent with how `VoiceSession.maintain_connection` already calls `Voice.leave_channel` directly. The `:presence` path continues to use `VoiceCommands` via `VoiceRuntime` (no circular dep there).

### Actor Type Change

Previously, `play_sound` actors from the web layer were plain username strings (e.g. `"justin"`), and from the API layer, a map `%{display_name: username, user_id: db_id}`. Neither carried a `discord_id` usable for voice channel lookup.

Both callers now pass the full `%Soundboard.Accounts.User{}` struct. `PlaybackEngine` already handled this type (via `actor_display_name/1` and `actor_user_id/1` pattern matches), so no downstream changes were needed. The new `actor_discord_id/1` private function in `AudioPlayer` extracts the Discord ID for auto-join, and falls back to `nil` for strings and maps without `discord_id`.

---

## New Function: `VoicePresence.find_user_voice_channel/1`

```elixir
@spec find_user_voice_channel(String.t()) ::
        {:ok, {guild_id :: String.t(), channel_id :: String.t()}} | :not_found
```

Iterates over all guilds in the EDA guild cache and returns the first voice state matching the given Discord user ID. Returns `:not_found` if the user is not in any voice channel or the cache is unavailable.

---

## New Module: `IdleTimeoutPolicy`

```
lib/soundboard/discord/handler/idle_timeout_policy.ex
```

Reads the `VOICE_IDLE_TIMEOUT_SECONDS` environment variable. Returns the timeout in milliseconds, or `nil` if the value is ≤ 0.

---

## Configuration

| Variable | Default | Description |
|---|---|---|
| `AUTO_JOIN` | `play` | Join/leave mode. `play` — join on sound playback, leave on idle or last user. `presence` — follow users in, leave when alone. `false` — manual only, leave after idle timeout once alone. |
| `VOICE_IDLE_TIMEOUT_SECONDS` | `600` | Seconds of inactivity before auto-leave. Set to `0` to disable. In `play` mode: resets on each sound played. In `false` mode: starts when last user leaves, cancels if a user rejoins. In `presence` mode: ignored. |

---

## Database Changes

**None.**

---

## File Inventory

| File | Action |
|---|---|
| `lib/soundboard/discord/handler/auto_join_policy.ex` | **Modify** — boolean → enum (`:presence`, `:play`, `false`); remove `enabled?/0` |
| `lib/soundboard/discord/handler/idle_timeout_policy.ex` | **New** — `VOICE_IDLE_TIMEOUT_SECONDS` config reader; returns `nil` when disabled |
| `lib/soundboard/discord/handler/voice_presence.ex` | **Modify** — add `find_user_voice_channel/1` |
| `lib/soundboard/discord/handler/voice_runtime.ex` | **Modify** — mode-aware connect/disconnect routing; `bot_alone_action/1`; `handle_user_rejoin_cancel/1` |
| `lib/soundboard/audio_player.ex` | **Modify** — mode-gated auto-join, idle timer, and last-user-left; new `last_user_left/1` and `user_joined_channel/1` public API |
| `lib/soundboard_web/live/support/sound_playback.ex` | **Modify** — pass `%User{}` struct instead of username string |
| `lib/soundboard_web/controllers/api/sound_controller.ex` | **Modify** — pass `%User{}` struct instead of display-name map |
| `test/soundboard/discord/handler/auto_join_policy_test.exs` | **Rewrite** — enum mode tests; removed `enabled?/0` tests |
| `test/soundboard/discord/handler/idle_timeout_policy_test.exs` | **New** — covers default, custom value, whitespace, disabled (0 and negative) |
| `test/soundboard/discord/handler/voice_presence_test.exs` | **New** — covers `find_user_voice_channel/1` |
| `test/soundboard/discord/handler/voice_runtime_test.exs` | **Modify** — updated mocks to `:presence`; new tests for `play`/`false` mode routing and user-rejoin cancel |
| `test/soundboard_web/audio_player_test.exs` | **Modify** — mode-gated idle timeout and auto-join tests; new `last_user_left` and `user_joined_channel` tests |
| `test/soundboard_web/discord_handler_test.exs` | **Modify** — presence-mode mock; updated leave-sequence assertion |
| `test/soundboard_web/plugs/api_auth_db_token_test.exs` | **Modify** — actor assertion updated |
| `test/soundboard_web/controllers/api/sound_controller_test.exs` | **Modify** — actor assertion updated |
| `test/soundboard_web/live/favorites_live_test.exs` | **Modify** — actor assertion updated |

---

## Testing Strategy

| Layer | What is tested |
|---|---|
| `AutoJoinPolicy` | Test env → `:play`. Default (no env var) → `:play`. `play` → `:play`. `presence`/truthy → `:presence`. `false`/falsy/unknown → `false`. |
| `IdleTimeoutPolicy` | Default → 600,000 ms. Custom value. Whitespace trimming. `0` → `nil`. Negative → `nil`. |
| `VoicePresence.find_user_voice_channel` | User found in a guild. User not found. User in guild but no channel. Multi-guild search. Cache unavailable. |
| `VoiceRuntime` | `handle_disconnect` notifies `AudioPlayer.last_user_left` in `:play` and `false` modes. `handle_connect` cancels idle timer via `AudioPlayer.user_joined_channel` in `false` mode. Bootstrap skips guild scan in `:play` mode. |
| `AudioPlayer` — idle timeout | Timer scheduled on `set_voice_channel` in `:play` mode only. Not scheduled in `:presence` or `false` mode. Timer cancelled on clear. Timer reset on `play_sound` in `:play` mode only (not reset in `:presence`). Timer fires → leave called, state cleared. Stale token ignored. |
| `AudioPlayer` — `last_user_left` | Leaves immediately in `:play` and `:presence` modes. Starts idle timer in `false` mode with timeout. No-ops in `false` mode with timeout disabled. Ignores call when bot is not in a channel. |
| `AudioPlayer` — `user_joined_channel` | Cancels idle timer. |
| `AudioPlayer` — auto-join | User with `discord_id` in a voice channel → join called, `voice_channel` set, idle timer started (`:play` mode). User not in any channel → error, no join. Actor without `discord_id` → no lookup attempted. Auto-join skipped in `false` mode. |

---

## Out of Scope

- **Auto-join for `!play` Discord commands**: the `!play` command handler already requires `!join` first; that flow was not changed.
- **Per-guild or per-channel idle timeout**: the timeout is global. Future work could make it configurable per guild via DB settings.
- **Idle timeout reset on playback *finish*** (as opposed to *start*): the timer resets when a sound is cast, not when it finishes playing. This means the clock starts as soon as playback is requested, not after the sound ends. For typical usage (sounds are 1–30 seconds) this makes no practical difference.
- **Notifying users before leaving**: the bot does not send a Discord message warning that it is about to leave due to inactivity.
- **`false` mode last-user-leave recheck delay**: the 1.5-second recheck-alone logic in `VoiceRuntime` applies for all modes, including `false`. The idle timer in `false` mode does not start until the recheck confirms the bot is alone.


================================================
FILE: docs/specs/youtube-playback.md
================================================
# Spec: YouTube Video Playback via Discord Bot Command

**Status:** Draft  
**Date:** 2026-03-07  
**Author:** —

---

## Summary

Add a `!play <youtube_url>` Discord bot command that extracts the audio from a YouTube video and plays it through the bot's current voice channel. This is an ephemeral, on-demand playback — it does **not** save the YouTube audio as a permanent sound in the library.

---

## Motivation

Users currently play pre-uploaded sounds (local files or direct URLs) via the soundboard. There is no way to quickly share and play a YouTube video's audio in voice chat without first downloading, converting, and uploading it. A `!play` command eliminates that friction.

---

## User-Facing Behavior

### Commands

| Command | Description |
|---|---|
| `!play <youtube_url>` | Extract audio from the YouTube URL and play it in the bot's current voice channel. |
| `!play <youtube_url> <volume>` | Same as above, with an explicit volume (0.0–1.5, default 1.0). |
| `!stop` | Stop whatever is currently playing (already exists — no change). |

### Responses

| Scenario | Bot Reply |
|---|---|
| Success | 🎵 Now playing: `<video_title>` |
| Bot not in a voice channel | "I'm not in a voice channel. Use `!join` first." |
| Invalid / unsupported URL | "That doesn't look like a valid YouTube URL." |
| yt-dlp extraction fails | "Failed to fetch audio from that URL. It may be private, age-restricted, or region-locked." |
| Already playing (interrupt) | Stops current sound, starts YouTube audio (existing interrupt behavior). |

---

## Architecture & Design

### High-Level Flow

```
Discord message "!play <url>"
  │
  ▼
CommandHandler.handle_message/1          ← parse command, validate URL format
  │
  ▼
Soundboard.YouTube.Extractor            ← NEW module: call yt-dlp, return audio stream URL + metadata
  │
  ▼
AudioPlayer.play_youtube/3               ← NEW public API on AudioPlayer GenServer
  │
  ▼
PlaybackQueue / PlaybackEngine           ← reuse existing queue & engine (plays URL type)
  │
  ▼
Discord Voice (EDA)                      ← ffmpeg reads the stream URL, sends RTP
```

### New Modules

#### 1. `Soundboard.YouTube.Extractor`

Wraps the `yt-dlp` CLI directly via `System.cmd/3`.

**Responsibilities:**
- Validate that a URL is a supported YouTube link via a regex matching `youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`.
- Extract metadata + stream URL in a **single** `yt-dlp` invocation using combined flags: `yt-dlp --get-title --get-url --get-duration -f bestaudio --no-playlist <url>`.
- Parse the multi-line stdout (line 1: title, line 2: stream URL, line 3: duration) into a struct.
- Enforce a maximum duration (configurable, default: 10 minutes / 600s) to prevent abuse.
- Wrap the call in `Task.async` + `Task.yield/2` with a configurable timeout (default: 15s) to avoid hanging on slow networks or unresponsive URLs.
- Return `{:ok, %{stream_url: url, title: title, duration_seconds: integer}}` or `{:error, reason}` with user-friendly messages derived from stderr.

**Key design decisions:**
- Always use the list-of-args form of `System.cmd/3` — never shell interpolation — to prevent command injection.
- `--no-playlist` flag to prevent accidentally queuing an entire playlist.
- `-f bestaudio` to get an audio-only stream URL that ffmpeg can consume directly.
- Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern.
- Availability check via `System.find_executable("yt-dlp")` or configured path, cached in `persistent_term` on first call.
- In tests, the module can be mocked via `Mox` or by making the system-cmd call go through a configurable function/module.

**Public API:**
```elixir
@spec extract(String.t()) :: {:ok, extraction()} | {:error, String.t()}
@spec valid_url?(String.t()) :: boolean()
@spec available?() :: boolean()
```

### Modified Modules

#### `Soundboard.Discord.Handler.CommandHandler`

Add a new clause:

```elixir
def handle_message(%{content: "!play " <> url_and_args} = msg)
```

- Parse the URL (first token) and optional volume (second token).
- Validate URL format via `YouTube.Extractor.valid_youtube_url?/1`.
- Check that the bot is in a voice channel (`AudioPlayer.current_voice_channel/0`).
- On validation pass, call `AudioPlayer.play_youtube(url, volume, actor)`.
- Reply with an appropriate Discord message (see table above).

#### `Soundboard.AudioPlayer` (GenServer)

Add a new public function and cast:

```elixir
def play_youtube(url, volume \\ 1.0, actor)
```

Internally sends `{:play_youtube, url, volume, actor}`. The `handle_cast` will:
1. Call `YouTube.Extractor.extract(url)`.
2. On success, build a play request (similar to `PlaybackQueue.build_request/3` but using the extracted stream URL and supplied volume directly, bypassing `SoundLibrary`).
3. Enqueue via `PlaybackQueue.enqueue/3` — reusing all existing interrupt/retry logic.

#### `Soundboard.AudioPlayer.PlaybackEngine`

No changes expected. The engine already supports `:url` play type, and the extracted stream URL is a direct audio URL that ffmpeg handles natively.

#### `Soundboard.AudioPlayer.PlaybackQueue`

Add a new `build_youtube_request/4` function (or extend `build_request`) that creates a play request from a raw URL + volume instead of looking up `SoundLibrary`:

```elixir
@spec build_youtube_request({String.t(), String.t()}, String.t(), number(), term()) ::
        {:ok, play_request()}
def build_youtube_request({guild_id, channel_id}, stream_url, volume, actor)
```

The `sound_name` field in the request will be set to the video title (for display in notifications).

### Stats / Tracking

YouTube plays are **not** tracked in the `stats.plays` table (they aren't library sounds). The `PlaybackEngine` already skips tracking for system users — we can use a similar mechanism, or simply not call `track_play_if_needed` for YouTube plays. The `Notifier.sound_played/2` broadcast will still fire so the LiveView shows "User played <video title>".

---

## Dependencies

### Hex

**None.** We wrap `yt-dlp` directly via `System.cmd/3` — no third-party Hex packages needed.

We evaluated `exyt_dlp` (~> 0.1.6) and decided against it. The library is a thin pass-through to `System.cmd("yt-dlp", params)` with no timeout support, no combined-flag calls, and opaque error handling (`:invalid_youtube_url_or_params` for everything). Our own wrapper is ~60 lines, gives us full control, and avoids a low-activity single-maintainer dependency.

### System: `yt-dlp`

`yt-dlp` is a **system dependency** that must be installed on the host.

| Environment | Installation |
|---|---|
| Local dev | `brew install yt-dlp` / `pip install yt-dlp` |
| Docker | Add `RUN pip install yt-dlp` (or grab the static binary) to the Dockerfile |

The application should gracefully degrade: if `yt-dlp` is not found, `!play` replies with "YouTube playback is not available (yt-dlp not installed)." Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern in `PlaybackEngine`.

---

## Configuration

Add to `config/config.exs` (or runtime config):

```elixir
config :soundboard, :ytdlp_executable, :system          # :system | false | "/path/to/yt-dlp"
config :soundboard, :youtube_max_duration_seconds, 600   # 10 minutes
config :soundboard, :ytdlp_timeout_ms, 15_000            # extraction timeout
```

---

## Database Changes

**None.** YouTube plays are ephemeral and not persisted.

---

## File Inventory (new & changed)

| File | Action |
|---|---|
| `lib/soundboard/youtube/extractor.ex` | **New** — yt-dlp wrapper |

| `lib/soundboard/discord/handler/command_handler.ex` | **Modify** — add `!play` clause |
| `lib/soundboard/audio_player.ex` | **Modify** — add `play_youtube/3` cast |
| `lib/soundboard/audio_player/playback_queue.ex` | **Modify** — add `build_youtube_request/4` |
| `Dockerfile` | **Modify** — install `yt-dlp` |
| `config/config.exs` | **Modify** — add youtube config keys |
| `test/soundboard/youtube/extractor_test.exs` | **New** — covers extraction + URL validation |
| `test/soundboard/discord/handler/command_handler_test.exs` | **Modify** — add `!play` tests |
| `test/soundboard/audio_player_test.exs` | **Modify** — add youtube cast tests |

---

## Security Considerations

- **Input sanitization:** The YouTube URL is passed as an argument to `System.cmd/3`. Use the list-of-args form (`System.cmd("yt-dlp", [args...])`) — never shell interpolation — to prevent command injection.
- **Duration cap:** Enforce the max duration to prevent a user from streaming a 10-hour video and monopolizing the voice channel.
- **Rate limiting:** (Future / optional) Consider a per-user cooldown on `!play` to prevent spam. Not in scope for v1 but worth noting.
- **No disk writes:** The stream URL approach means no temp files accumulate on the server.

---

## Testing Strategy

| Layer | What to test |
|---|---|
| `YouTube.Extractor` | URL validation (valid/invalid/edge cases: `watch?v=`, `youtu.be/`, shorts, playlist URLs rejected, non-YouTube rejected). Mock `System.cmd` to test parse logic for yt-dlp stdout. Timeout handling. Duration enforcement. Missing binary. |
| `CommandHandler` | `!play` with valid URL dispatches to `AudioPlayer`. `!play` with garbage URL returns error message. `!play` with no args returns usage hint. Bot not in channel returns error. |
| `AudioPlayer` | `play_youtube` cast flows through to `PlaybackQueue`. Integration with mock voice. |
| Manual / integration | End-to-end: bot in voice → `!play https://youtu.be/dQw4w9WgXcQ` → audio plays in Discord. |

---

## Out of Scope (future enhancements)

- Saving a YouTube sound to the library permanently ("!save" command).
- Queue / playlist support (multiple `!play` commands queued in order).
- Playback controls (`!pause`, `!resume`, `!skip`).
- Playing from other platforms (SoundCloud, Spotify, etc.).
- Web UI integration (play YouTube from the LiveView).
- Now-playing status / progress indicator in Discord or the web UI.

---

## Open Questions

1. **Should `!play` also accept non-YouTube URLs?** yt-dlp supports hundreds of sites. We could allow any yt-dlp-supported URL, or restrict to YouTube only for v1. Restricting is simpler and safer — recommend YouTube-only for now.
2. **Should the extraction happen in the `CommandHandler` (before casting to `AudioPlayer`) or inside the `AudioPlayer` GenServer?** Doing it in a Task spawned by `CommandHandler` keeps the AudioPlayer GenServer responsive. However, the current flow already uses `Task.async` inside `PlaybackQueue.start_playback/2`, so doing extraction inside the AudioPlayer cast is consistent. **Recommendation:** Extract in the AudioPlayer cast (inside the spawned playback Task) so the command handler remains fast and the reply can be sent immediately ("⏳ Fetching audio…").
3. **Max duration default?** 10 minutes seems reasonable. Should this be configurable per-guild or global? **Recommendation:** Global config for v1.


================================================
FILE: entrypoint.sh
================================================
#!/bin/sh

# Run migrations
echo "Running database migrations..."
mix ecto.migrate

# Start Phoenix server in foreground
# Using exec ensures proper signal handling and process management
echo "Starting Phoenix server..."
exec mix phx.server


================================================
FILE: lib/soundboard/accounts/api_token.ex
================================================
defmodule Soundboard.Accounts.ApiToken do
  @moduledoc """
  API access token bound to a user.

  The token hash is used for verification. The plaintext token is also persisted
  so the Settings UI can display and copy active tokens after creation.
  """
  use Ecto.Schema
  import Ecto.Changeset
  alias Soundboard.Accounts.User

  @type t :: %__MODULE__{
          id: integer() | nil,
          user_id: integer() | nil,
          user: User.t() | Ecto.Association.NotLoaded.t() | nil,
          token_hash: String.t() | nil,
          token: String.t() | nil,
          label: String.t() | nil,
          revoked_at: NaiveDateTime.t() | nil,
          last_used_at: NaiveDateTime.t() | nil,
          inserted_at: NaiveDateTime.t() | nil,
          updated_at: NaiveDateTime.t() | nil
        }

  schema "api_tokens" do
    belongs_to :user, User
    field :token_hash, :string
    field :token, :string
    field :label, :string
    field :revoked_at, :naive_datetime
    field :last_used_at, :naive_datetime

    timestamps()
  end

  def changeset(token, attrs) do
    token
    |> cast(attrs, [:user_id, :token_hash, :token, :label, :revoked_at, :last_used_at])
    |> validate_required([:user_id, :token_hash])
    |> unique_constraint(:token_hash)
    |> assoc_constraint(:user)
  end
end


================================================
FILE: lib/soundboard/accounts/api_tokens.ex
================================================
defmodule Soundboard.Accounts.ApiTokens do
  @moduledoc """
  Context for managing API tokens bound to users.
  """
  require Logger

  import Ecto.Query
  alias Soundboard.Accounts.{ApiToken, User}
  alias Soundboard.Repo

  @type verify_error :: :invalid | :token_update_failed
  @type verify_result :: {:ok, User.t(), ApiToken.t()} | {:error, verify_error}
  @type revoke_result ::
          {:ok, ApiToken.t()} | {:error, :forbidden | :not_found | Ecto.Changeset.t()}

  @prefix "sb_"

  @spec list_tokens(User.t()) :: [ApiToken.t()]
  def list_tokens(%User{id: user_id}) do
    from(t in ApiToken,
      where: t.user_id == ^user_id and is_nil(t.revoked_at),
      order_by: [desc: t.inserted_at]
    )
    |> Repo.all()
  end

  @spec generate_token(User.t(), map()) ::
          {:ok, String.t(), ApiToken.t()} | {:error, Ecto.Changeset.t()}
  def generate_token(%User{id: user_id}, attrs \\ %{}) do
    raw = random_token()
    hash = hash_token(raw)

    changeset =
      %ApiToken{}
      |> ApiToken.changeset(%{
        user_id: user_id,
        token_hash: hash,
        token: raw,
        label: Map.get(attrs, "label") || Map.get(attrs, :label)
      })

    case Repo.insert(changeset) do
      {:ok, token} -> {:ok, raw, token}
      {:error, changeset} -> {:error, changeset}
    end
  end

  @spec verify_token(String.t()) :: verify_result()
  def verify_token(raw) when is_binary(raw) do
    query =
      from t in ApiToken,
        where: t.token_hash == ^hash_token(raw) and is_nil(t.revoked_at)

    case Repo.one(query) do
      nil ->
        {:error, :invalid}

      token ->
        token = Repo.preload(token, :user)

        case update_last_used_at(token) do
          {:ok, _updated_token} ->
            {:ok, token.user, token}

          {:error, changeset} ->
            Logger.error("Failed to update API token last_used_at: #{inspect(changeset.errors)}")
            {:error, :token_update_failed}
        end
    end
  end

  @spec revoke_token(User.t(), integer() | String.t()) :: revoke_result()
  def revoke_token(%User{id: user_id}, token_id) do
    token_id = normalize_id(token_id)

    case Repo.get(ApiToken, token_id) do
      %ApiToken{user_id: ^user_id} = token ->
        token
        |> Ecto.Changeset.change(
          revoked_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
        )
        |> Repo.update()

      %ApiToken{} ->
        {:error, :forbidden}

      nil ->
        {:error, :not_found}
    end
  end

  defp update_last_used_at(%ApiToken{} = token) do
    token
    |> Ecto.Changeset.change(
      last_used_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
    )
    |> Repo.update()
  end

  defp random_token do
    @prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
  end

  defp hash_token(raw) do
    :crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
  end

  defp normalize_id(id) when is_integer(id), do: id

  defp normalize_id(id) when is_binary(id) do
    case Integer.parse(id) do
      {int, ""} -> int
      _ -> -1
    end
  end
end


================================================
FILE: lib/soundboard/accounts/user.ex
================================================
defmodule Soundboard.Accounts.User do
  @moduledoc """
  The User module.
  """
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{
          id: integer() | nil,
          discord_id: String.t() | nil,
          username: String.t() | nil,
          avatar: String.t() | nil,
          inserted_at: NaiveDateTime.t() | nil,
          updated_at: NaiveDateTime.t() | nil
        }

  schema "users" do
    field :discord_id, :string
    field :username, :string
    field :avatar, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:discord_id, :username, :avatar])
    |> validate_required([:discord_id, :username])
    |> unique_constraint(:discord_id)
  end
end


================================================
FILE: lib/soundboard/accounts.ex
================================================
defmodule Soundboard.Accounts do
  @moduledoc """
  Accounts boundary helpers used by web and runtime code.
  """

  alias Soundboard.Accounts.User
  alias Soundboard.Repo
  import Ecto.Query

  def get_user(user_id), do: Repo.get(User, user_id)

  def avatars_by_usernames([]), do: %{}

  def avatars_by_usernames(usernames) when is_list(usernames) do
    from(u in User, where: u.username in ^usernames, select: {u.username, u.avatar})
    |> Repo.all()
    |> Map.new()
  end
end


================================================
FILE: lib/soundboard/application.ex
================================================
defmodule Soundboard.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application
  alias Soundboard.Discord.RuntimeCapability
  require Logger

  @impl true
  def start(_type, _args) do
    Logger.info("Starting Soundboard Application")

    children = [
      Soundboard.Repo,
      {Soundboard.AudioPlayer, []},
      SoundboardWeb.Telemetry,
      {Phoenix.PubSub, name: Soundboard.PubSub},
      SoundboardWeb.Presence,
      SoundboardWeb.PresenceHandler,
      Soundboard.Discord.Handler.State,
      SoundboardWeb.Endpoint
      | discord_children()
    ]

    opts = [strategy: :one_for_one, name: Soundboard.Supervisor]
    Supervisor.start_link(children, opts)
  end

  defp discord_children do
    if RuntimeCapability.discord_handler_enabled?() do
      [Soundboard.Discord.Handler]
    else
      RuntimeCapability.log_degraded_mode()
      []
    end
  end

  # Tell Phoenix to update the endpoint configuration
  # whenever the application is updated.
  @impl true
  def config_change(changed, _new, removed) do
    SoundboardWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end


================================================
FILE: lib/soundboard/audio_player/notifier.ex
================================================
defmodule Soundboard.AudioPlayer.Notifier do
  @moduledoc false

  alias Soundboard.PubSubTopics

  def sound_played(sound_name, actor_name) do
    PubSubTopics.broadcast_sound_played(sound_name, actor_name)
  end

  def error(message) do
    PubSubTopics.broadcast_error(message)
  end
end


================================================
FILE: lib/soundboard/audio_player/playback_engine.ex
================================================
defmodule Soundboard.AudioPlayer.PlaybackEngine do
  @moduledoc false

  require Logger

  alias Soundboard.Accounts.User
  alias Soundboard.{AudioPlayer, AudioPlayer.Notifier, AudioPlayer.SoundLibrary, Discord.Voice}

  @system_users ["System"]
  @rtp_probe_poll_ms 20
  @rtp_probe_default_timeout_ms 6_000
  @voice_not_ready_retry_ms 350
  @voice_ready_poll_ms 100
  @voice_ready_timeout_ms 4_000
  @voice_ready_fast_timeout_ms 1_200
  @voice_settle_ms 120
  @rejoin_retry_threshold 3
  @max_play_attempts 20

  def play(guild_id, channel_id, sound_name, path_or_url, volume, actor) do
    join_state = ensure_joined_channel(guild_id, channel_id)
    maybe_settle_before_play(join_state)
    submit_play_request(guild_id, sound_name, path_or_url, volume, actor)
  end

  defp maybe_settle_before_play({:joined, :ok}) do
    Process.sleep(@voice_settle_ms)
  end

  defp maybe_settle_before_play(_), do: :ok

  defp submit_play_request(guild_id, sound_name, path_or_url, volume, actor) do
    if is_nil(ffmpeg_executable()) do
      Logger.error("ffmpeg not found in PATH. Cannot play #{sound_name}")
      broadcast_error("ffmpeg is not installed on this host")
      :error
    else
      {play_input, play_type} = SoundLibrary.prepare_play_input(sound_name, path_or_url)

      play_request = %{
        guild_id: guild_id,
        play_input: play_input,
        play_type: play_type,
        play_options: [volume: clamp_volume(volume)],
        sound_name: sound_name,
        actor: actor
      }

      play_with_retries(play_request, 0, false)
    end
  end

  defp play_with_retries(play_request, attempt, refresh_attempted)
       when attempt < @max_play_attempts do
    case voice_play(play_request) |> classify_play_attempt() do
      :ok ->
        maybe_probe_first_rtp(play_request.guild_id, play_request.sound_name, attempt + 1)
        track_play_if_needed(play_request.sound_name, play_request.actor)
        broadcast_success(play_request.sound_name, play_request.actor)
        :ok

      {:retry, retry} ->
        retry_play_attempt(play_request, attempt, refresh_attempted, retry)

      {:error, reason} ->
        Logger.error("Voice.play failed: #{inspect(reason)} (attempt #{attempt + 1})")
        broadcast_error("Failed to play sound: #{reason}")
        :error
    end
  end

  defp play_with_retries(%{sound_name: sound_name}, attempt, _refresh_attempted) do
    Logger.error("Exceeded max retries (#{attempt}) for playing #{sound_name}")
    broadcast_error("Failed to play sound after multiple attempts")
    :error
  end

  defp voice_play(play_request) do
    Voice.play(
      play_request.guild_id,
      play_request.play_input,
      play_request.play_type,
      play_request.play_options
    )
  end

  defp classify_play_attempt(:ok), do: :ok

  defp classify_play_attempt({:error, "Audio already playing in voice channel."}) do
    {:retry,
     %{
       log: "Audio still playing, stopping and retrying...",
       sleep_ms: 50,
       stop_first?: true,
       force_refresh?: false
     }}
  end

  defp classify_play_attempt({:error, "Must be connected to voice channel to play audio."}) do
    {:retry,
     %{
       log: "Voice reported not connected, waiting before retry...",
       sleep_ms: @voice_not_ready_retry_ms,
       stop_first?: false,
       force_refresh?: false
     }}
  end

  defp classify_play_attempt({:error, "Voice session is still negotiating encryption."}) do
    {:retry,
     %{
       log:
         "Voice encryption not ready yet, waiting #{@voice_not_ready_retry_ms}ms before retry...",
       sleep_ms: @voice_not_ready_retry_ms,
       stop_first?: false,
       force_refresh?: true
     }}
  end

  defp classify_play_attempt({:error, reason}), do: {:error, reason}
  defp classify_play_attempt(other), do: {:error, inspect(other)}

  defp retry_play_attempt(play_request, attempt, refresh_attempted, retry) do
    Logger.warning("#{retry.log} (attempt #{attempt + 1})")

    if retry.stop_first? do
      Voice.stop(play_request.guild_id)
    end

    refresh_attempted =
      maybe_trigger_rejoin(
        play_request.guild_id,
        attempt,
        refresh_attempted,
        retry.force_refresh?
      )

    Process.sleep(retry.sleep_ms)
    play_with_retries(play_request, attempt + 1, refresh_attempted)
  end

  defp maybe_trigger_rejoin(guild_id, attempt, refresh_attempted, force_refresh) do
    if attempt >= @rejoin_retry_threshold and not refresh_attempted do
      maybe_rejoin_current_channel(guild_id, force_refresh)
      true
    else
      refresh_attempted
    end
  end

  defp maybe_rejoin_current_channel(guild_id, force_refresh) do
    case AudioPlayer.current_voice_channel() do
      {:ok, {^guild_id, channel_id}} ->
        maybe_rejoin_for_channel(guild_id, channel_id, force_refresh)

      {:ok, _other_channel} ->
        :ok

      {:error, reason} ->
        Logger.debug("Skipping rejoin lookup for guild #{guild_id}: #{inspect(reason)}")
        :ok
    end

    :ok
  end

  defp maybe_rejoin_for_channel(guild_id, channel_id, true) do
    joined? = Voice.channel_id(guild_id) == to_string(channel_id)
    ready? = match?({:ok, true}, safe_voice_ready(guild_id))

    cond do
      joined? and not ready? ->
        refresh_voice_session(guild_id, channel_id)

      joined? and ready? ->
        Logger.debug("Skipping refresh; voice already ready in channel #{channel_id}")

      true ->
        rejoin_voice_channel(guild_id, channel_id)
    end
  end

  defp maybe_rejoin_for_channel(guild_id, channel_id, false) do
    joined? = Voice.channel_id(guild_id) == to_string(channel_id)
    ready? = match?({:ok, true}, safe_voice_ready(guild_id))

    if joined? and ready? do
      Logger.debug("Skipping rejoin; already in voice channel #{channel_id}")
    else
      rejoin_voice_channel(guild_id, channel_id)
    end
  end

  defp refresh_voice_session(guild_id, channel_id) do
    reconnect_voice_session(
      guild_id,
      channel_id,
      "Refreshing voice session in channel #{channel_id} with in-place rejoin"
    )
  end

  defp rejoin_voice_channel(guild_id, channel_id) do
    reconnect_voice_session(guild_id, channel_id, "Rejoining voice channel #{channel_id}")
  end

  defp reconnect_voice_session(guild_id, channel_id, log_message) do
    Logger.info(log_message)
    Voice.join_channel(guild_id, channel_id)
    wait_for_voice_ready(guild_id)
  end

  defp ensure_joined_channel(guild_id, channel_id) do
    if Voice.channel_id(guild_id) == to_string(channel_id) do
      {:already_joined, wait_for_voice_ready(guild_id, @voice_ready_fast_timeout_ms)}
    else
      Logger.info("Joining voice channel #{channel_id}")
      Voice.join_channel(guild_id, channel_id)
      Process.sleep(150)
      {:joined, wait_for_voice_ready(guild_id)}
    end
  end

  defp maybe_probe_first_rtp(guild_id, sound_name, attempt_number) do
    if Application.get_env(:soundboard, :voice_rtp_probe, false) do
      timeout_ms =
        Application.get_env(
          :soundboard,
          :voice_rtp_probe_timeout_ms,
          @rtp_probe_default_timeout_ms
        )

      initial_seq = current_rtp_sequence(guild_id)
      started_at = System.monotonic_time(:millisecond)

      Task.start(fn ->
        wait_for_first_rtp(
          guild_id,
          sound_name,
          attempt_number,
          initial_seq,
          started_at,
          timeout_ms
        )
      end)
    end

    :ok
  end

  defp wait_for_first_rtp(
         guild_id,
         sound_name,
         attempt_number,
         initial_seq,
         started_at,
         timeout_ms
       ) do
    elapsed_ms = System.monotonic_time(:millisecond) - started_at
    current_seq = current_rtp_sequence(guild_id)
    initial_seq_value = unwrap_sequence(initial_seq)
    current_seq_value = unwrap_sequence(current_seq)

    cond do
      is_integer(initial_seq_value) and is_integer(current_seq_value) and
          current_seq_value != initial_seq_value ->
        Logger.info(
          "RTP probe: first packet for #{sound_name} after #{elapsed_ms}ms " <>
            "(attempt #{attempt_number}, seq #{initial_seq_value} -> #{current_seq_value})"
        )

      is_nil(initial_seq_value) and is_integer(current_seq_value) ->
        Logger.info(
          "RTP probe: sequence initialized for #{sound_name} after #{elapsed_ms}ms " <>
            "(attempt #{attempt_number}, seq #{current_seq_value})"
        )

      elapsed_ms >= timeout_ms ->
        status = safe_voice_status(guild_id)

        Logger.warning(
          "RTP probe: no progress for #{sound_name} within #{timeout_ms}ms " <>
            "(attempt #{attempt_number}, initial_seq=#{inspect(initial_seq)}, " <>
            "current_seq=#{inspect(current_seq)}, channel=#{inspect(status.channel)}, " <>
            "playing=#{inspect(status.playing)})"
        )

      true ->
        Process.sleep(@rtp_probe_poll_ms)

        wait_for_first_rtp(
          guild_id,
          sound_name,
          attempt_number,
          initial_seq,
          started_at,
          timeout_ms
        )
    end
  end

  defp wait_for_voice_ready(guild_id, timeout_ms \\ @voice_ready_timeout_ms) do
    started_at = System.monotonic_time(:millisecond)
    do_wait_for_voice_ready(guild_id, started_at, timeout_ms)
  end

  defp do_wait_for_voice_ready(guild_id, started_at, timeout_ms) do
    cond do
      match?({:ok, true}, safe_voice_ready(guild_id)) ->
        :ok

      System.monotonic_time(:millisecond) - started_at >= timeout_ms ->
        Logger.warning(
          "Timed out waiting for voice readiness in guild #{guild_id} " <>
            "(channel=#{inspect(safe_voice_channel(guild_id))})"
        )

        :timeout

      true ->
        Process.sleep(@voice_ready_poll_ms)
        do_wait_for_voice_ready(guild_id, started_at, timeout_ms)
    end
  end

  defp current_rtp_sequence(guild_id) do
    case Voice.get_voice(guild_id) do
      {:ok, %{rtp_sequence: seq}} when is_integer(seq) -> {:ok, seq}
      {:ok, _state} -> {:ok, nil}
      {:error, reason} -> {:error, {:voice_state_unavailable, reason}}
    end
  rescue
    error -> {:error, {:voice_state_unavailable, Exception.message(error)}}
  end

  defp safe_voice_status(guild_id) do
    %{
      channel: safe_voice_channel(guild_id),
      playing: safe_voice_playing(guild_id)
    }
  end

  defp safe_voice_ready(guild_id) do
    {:ok, Voice.ready?(guild_id)}
  rescue
    error -> {:error, {:voice_not_ready, Exception.message(error)}}
  end

  defp safe_voice_channel(guild_id) do
    {:ok, Voice.channel_id(guild_id)}
  rescue
    error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}
  end

  defp safe_voice_playing(guild_id) do
    {:ok, Voice.playing?(guild_id)}
  rescue
    error -> {:error, {:voice_playback_unavailable, Exception.message(error)}}
  end

  defp track_play_if_needed(sound_name, actor) do
    cond do
      system_user?(actor) ->
        :ok

      is_integer(actor_user_id(actor)) ->
        Soundboard.Stats.track_play(sound_name, actor_user_id(actor))

      is_binary(actor_display_name(actor)) ->
        username = actor_display_name(actor)

        case Soundboard.Repo.get_by(User, username: username) do
          %{id: user_id} -> Soundboard.Stats.track_play(sound_name, user_id)
          nil -> Logger.warning("Could not find user_id for #{username}")
        end

      true ->
        Logger.warning("Could not determine playback actor for #{sound_name}")
    end
  end

  defp broadcast_success(sound_name, actor) do
    Notifier.sound_played(sound_name, actor_display_name(actor) || "Unknown")
  end

  defp broadcast_error(message) do
    Notifier.error(message)
  end

  defp unwrap_sequence({:ok, sequence}), do: sequence
  defp unwrap_sequence({:error, _reason}), do: nil

  defp ffmpeg_executable do
    case Application.get_env(:soundboard, :ffmpeg_executable, :system) do
      :system -> System.find_executable("ffmpeg")
      false -> nil
      path when is_binary(path) -> path
    end
  end

  defp clamp_volume(value) when is_number(value) do
    value
    |> max(0.0)
    |> min(1.5)
    |> Float.round(4)
  end

  defp clamp_volume(_), do: 1.0

  defp actor_display_name(%{display_name: display_name}) when is_binary(display_name),
    do: display_name

  defp actor_display_name(%User{username: username}) when is_binary(username), do: username
  defp actor_display_name(username) when is_binary(username), do: username
  defp actor_display_name(_), do: nil

  defp actor_user_id(%{user_id: user_id}) when is_integer(user_id), do: user_id
  defp actor_user_id(%User{id: user_id}) when is_integer(user_id), do: user_id
  defp actor_user_id(_), do: nil

  defp system_user?(actor), do: actor_display_name(actor) in @system_users
end


================================================
FILE: lib/soundboard/audio_player/playback_queue.ex
================================================
defmodule Soundboard.AudioPlayer.PlaybackQueue do
  @moduledoc false

  require Logger

  alias Soundboard.AudioPlayer.{PlaybackEngine, SoundLibrary, State}
  alias Soundboard.Discord.Voice

  @type play_request :: %{
          guild_id: String.t(),
          channel_id: String.t(),
          sound_name: String.t(),
          path_or_url: String.t(),
          volume: number(),
          actor: term()
        }

  @spec build_request({String.t(), String.t()}, String.t(), term()) ::
          {:ok, play_request()} | {:error, String.t()}
  def build_request({guild_id, channel_id}, sound_name, actor) do
    case SoundLibrary.get_sound_path(sound_name) do
      {:ok, {path_or_url, volume}} ->
        {:ok,
         %{
           guild_id: guild_id,
           channel_id: channel_id,
           sound_name: sound_name,
           path_or_url: path_or_url,
           volume: volume,
           actor: actor
         }}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @spec enqueue(State.t(), play_request(), pos_integer()) :: State.t()
  def enqueue(%State{} = state, request, interrupt_watchdog_ms) do
    case state.current_playback do
      nil ->
        state
        |> cancel_interrupt_watchdog()
        |> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})
        |> start_playback(request)

      _ ->
        state
        |> Map.put(:pending_request, request)
        |> maybe_interrupt_current(interrupt_watchdog_ms)
    end
  end

  @spec clear_all(State.t()) :: State.t()
  def clear_all(%State{} = state) do
    state
    |> clear_current_playback()
    |> Map.merge(%{
      pending_request: nil,
      interrupting: false,
      interrupt_watchdog_attempt: 0
    })
  end

  @spec handle_task_result(State.t(), term()) :: State.t()
  def handle_task_result(
        %State{current_playback: %{sound_name: sound_name} = current} = state,
        result
      ) do
    case result do
      :ok ->
        %{
          state
          | current_playback:
              current
              |> Map.put(:task_ref, nil)
              |> Map.put(:task_pid, nil)
        }

      :error ->
        Logger.error("Playback start failed for #{sound_name}")
        state |> clear_current_playback() |> maybe_start_pending()
    end
  end

  @spec handle_task_down(State.t(), term()) :: State.t()
  def handle_task_down(%State{} = state, reason) do
    Logger.error("Playback task crashed: #{inspect(reason)}")
    state |> clear_current_playback() |> maybe_start_pending()
  end

  @spec handle_interrupt_watchdog(
          State.t(),
          String.t(),
          non_neg_integer(),
          pos_integer(),
          pos_integer()
        ) ::
          State.t()
  def handle_interrupt_watchdog(
        %State{interrupting: true, interrupt_watchdog_attempt: attempt} = state,
        guild_id,
        attempt,
        max_attempts,
        interrupt_watchdog_ms
      ) do
    cond do
      state.current_playback == nil ->
        state |> reset_interrupt_state() |> maybe_start_pending()

      attempt >= max_attempts ->
        Logger.warning(
          "Interrupt watchdog timed out for guild #{guild_id}; forcing latest request"
        )

        Voice.stop(guild_id)
        state |> clear_current_playback() |> maybe_start_pending()

      match?({:ok, true}, safe_voice_playing(guild_id)) ->
        Logger.debug(
          "Interrupt watchdog: audio still playing in guild #{guild_id}, retrying stop"
        )

        Voice.stop(guild_id)
        schedule_interrupt_watchdog(state, guild_id, attempt + 1, interrupt_watchdog_ms)

      true ->
        Logger.debug("Interrupt watchdog: playback already stopped for guild #{guild_id}")
        state |> clear_current_playback() |> maybe_start_pending()
    end
  end

  def handle_interrupt_watchdog(%State{} = state, _guild_id, _attempt, _max_attempts, _delay_ms),
    do: state

  @spec handle_playback_finished(State.t(), String.t()) :: State.t()
  def handle_playback_finished(%State{} = state, guild_id) do
    cond do
      match?(%{guild_id: ^guild_id}, state.current_playback) ->
        state
        |> clear_current_playback()
        |> maybe_start_pending()

      state.interrupting and match?({^guild_id, _}, state.voice_channel) ->
        state
        |> reset_interrupt_state()
        |> maybe_start_pending()

      true ->
        state
    end
  end

  defp start_playback(state, request) do
    task =
      Task.async(fn ->
        PlaybackEngine.play(
          request.guild_id,
          request.channel_id,
          request.sound_name,
          request.path_or_url,
          request.volume,
          request.actor
        )
      end)

    %{
      state
      | current_playback: request |> Map.put(:task_ref, task.ref) |> Map.put(:task_pid, task.pid)
    }
  end

  defp maybe_interrupt_current(%State{current_playback: %{guild_id: guild_id}} = state, delay_ms) do
    Logger.debug("Interrupting current playback in guild #{guild_id} for latest request")
    Voice.stop(guild_id)

    if match?({:ok, true}, safe_voice_playing(guild_id)) do
      state
      |> Map.put(:interrupting, true)
      |> schedule_interrupt_watchdog(guild_id, 1, delay_ms)
    else
      Logger.debug("Interrupt fast-path: playback stopped immediately in guild #{guild_id}")

      state
      |> clear_current_playback()
      |> maybe_start_pending()
    end
  end

  defp maybe_interrupt_current(%State{} = state, _delay_ms), do: state

  defp maybe_start_pending(%State{pending_request: nil} = state), do: state

  defp maybe_start_pending(%State{} = state) do
    request = state.pending_request

    case state.voice_channel do
      {guild_id, channel_id}
      when guild_id == request.guild_id and channel_id == request.channel_id ->
        state
        |> Map.put(:pending_request, nil)
        |> start_playback(request)

      _ ->
        %{state | pending_request: nil}
    end
  end

  defp clear_current_playback(%State{} = state) do
    cancel_playback_task(state.current_playback)

    state
    |> cancel_interrupt_watchdog()
    |> Map.merge(%{
      current_playback: nil,
      interrupting: false,
      interrupt_watchdog_attempt: 0
    })
  end

  defp reset_interrupt_state(%State{} = state) do
    state
    |> cancel_interrupt_watchdog()
    |> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})
  end

  defp schedule_interrupt_watchdog(%State{} = state, guild_id, attempt, delay_ms) do
    state = cancel_interrupt_watchdog(state)

    ref = Process.send_after(self(), {:interrupt_watchdog, guild_id, attempt}, delay_ms)

    %{state | interrupt_watchdog_ref: ref, interrupt_watchdog_attempt: attempt}
  end

  defp cancel_interrupt_watchdog(%State{interrupt_watchdog_ref: nil} = state), do: state

  defp cancel_interrupt_watchdog(%State{} = state) do
    Process.cancel_timer(state.interrupt_watchdog_ref)
    %{state | interrupt_watchdog_ref: nil}
  end

  defp cancel_playback_task(nil), do: :ok

  defp cancel_playback_task(%{task_pid: pid, task_ref: ref}) when is_pid(pid) do
    if is_reference(ref), do: Process.demonitor(ref, [:flush])

    if Process.alive?(pid) do
      Process.exit(pid, :kill)
    end

    :ok
  end

  defp cancel_playback_task(_), do: :ok

  defp safe_voice_playing(guild_id) do
    {:ok, Voice.playing?(guild_id)}
  rescue
    error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}
  end
end


================================================
FILE: lib/soundboard/audio_player/sound_library.ex
================================================
defmodule Soundboard.AudioPlayer.SoundLibrary do
  @moduledoc false

  require Logger

  alias Soundboard.Sound

  def ensure_cache do
    case :ets.info(:sound_meta_cache) do
      :undefined ->
        :ets.new(:sound_meta_cache, [:set, :named_table, :public, read_concurrency: true])
        :ok

      _ ->
        :ok
    end
  end

  def get_sound_path(sound_name) do
    ensure_cache()

    case lookup_cached_sound(sound_name) do
      {:hit, {_type, input, volume}} -> {:ok, {input, volume}}
      :miss -> resolve_and_cache_sound(sound_name)
    end
  end

  def prepare_play_input(sound_name, path_or_url) do
    ensure_cache()

    case :ets.lookup(:sound_meta_cache, sound_name) do
      [{^sound_name, %{source_type: source_type}}] when source_type in ["url", "local"] ->
        {path_or_url, :url}

      _ ->
        case Soundboard.Repo.get_by(Sound, filename: sound_name) do
          %{source_type: source_type} when source_type in ["url", "local"] ->
            {path_or_url, :url}

          _ ->
            Logger.warning("Unknown source type for #{sound_name}; defaulting to direct playback")
            {path_or_url, :url}
        end
    end
  end

  @doc """
  Removes any cached metadata for the given `sound_name` so future plays use fresh data.
  """
  def invalidate_cache(sound_name) when is_binary(sound_name) do
    ensure_cache()
    :ets.delete(:sound_meta_cache, sound_name)
    :ok
  end

  def invalidate_cache(_), do: :ok

  defp lookup_cached_sound(sound_name) do
    case :ets.lookup(:sound_meta_cache, sound_name) do
      [{^sound_name, %{source_type: source, input: input, volume: volume}}] ->
        {:hit, {source, input, volume}}

      _ ->
        :miss
    end
  end

  defp resolve_and_cache_sound(sound_name) do
    case Soundboard.Repo.get_by(Sound, filename: sound_name) do
      nil ->
        Logger.error("Sound not found in database: #{sound_name}")
        {:error, "Sound not found"}

      %{source_type: "url", url: url, volume: volume} when is_binary(url) ->
        meta = %{source_type: "url", input: url, volume: volume || 1.0}
        cache_sound(sound_name, meta)
        {:ok, {meta.input, meta.volume}}

      %{source_type: "local", filename: filename, volume: volume} when is_binary(filename) ->
        path = resolve_upload_path(filename)

        if File.exists?(path) do
          meta = %{source_type: "local", input: path, volume: volume || 1.0}
          cache_sound(sound_name, meta)
          {:ok, {meta.input, meta.volume}}
        else
          Logger.error("Local file not found: #{path}")
          {:error, "Sound file not found at #{path}"}
        end

      _sound ->
        Logger.error("Invalid sound configuration for #{sound_name}")
        {:error, "Invalid sound configuration"}
    end
  end

  defp resolve_upload_path(filename) do
    Soundboard.UploadsPath.file_path(filename)
  end

  defp cache_sound(sound_name, meta) do
    :ets.insert(:sound_meta_cache, {sound_name, meta})
  end
end


================================================
FILE: lib/soundboard/audio_player/voice_session.ex
================================================
defmodule Soundboard.AudioPlayer.VoiceSession do
  @moduledoc false

  require Logger

  alias Soundboard.AudioPlayer.State
  alias Soundboard.Discord.Voice

  @spec normalize_channel(term(), term()) :: {String.t(), String.t()} | nil
  def normalize_channel(guild_id, channel_id) do
    if is_nil(guild_id) or is_nil(channel_id) do
      nil
    else
      {guild_id, channel_id}
    end
  end

  @spec maintain_connection(State.t()) :: State.t()
  def maintain_connection(%State{voice_channel: {guild_id, channel_id}} = state)
      when not is_nil(guild_id) and not is_nil(channel_id) do
    guild_id
    |> maintenance_status(channel_id)
    |> perform_maintenance(state)
  end

  def maintain_connection(%State{} = state), do: state

  defp maintenance_status(guild_id, channel_id) do
    %{
      guild_id: guild_id,
      channel_id: channel_id,
      joined?: Voice.channel_id(guild_id) == to_string(channel_id),
      ready?: voice_ready(guild_id),
      playing?: voice_playing(guild_id)
    }
  end

  defp voice_ready(guild_id) do
    case safe_voice_ready(guild_id) do
      {:ok, value} ->
        value

      {:error, reason} ->
        Logger.warning("Voice readiness unavailable for guild #{guild_id}: #{inspect(reason)}")
        false
    end
  end

  defp voice_playing(guild_id) do
    case safe_voice_playing(guild_id) do
      {:ok, value} ->
        value

      {:error, reason} ->
        Logger.warning(
          "Voice playback status unavailable for guild #{guild_id}: #{inspect(reason)}; continuing maintenance"
        )

        false
    end
  end

  defp perform_maintenance(%{playing?: true}, state), do: state
  defp perform_maintenance(%{joined?: true, ready?: true}, state), do: state

  defp perform_maintenance(%{joined?: true} = status, state) do
    Logger.warning(
      "Voice session unready for guild #{status.guild_id} in channel #{status.channel_id}, forcing leave→rejoin"
    )

    try do
      Voice.leave_channel(status.guild_id)
    rescue
      error -> Logger.warning("Voice leave failed during reset: #{inspect(error)}")
    end

    Process.sleep(1_000)
    attempt_voice_join(state, status.guild_id, status.channel_id, "rejoin after stale session")
  end

  defp perform_maintenance(status, state) do
    Logger.warning(
      "Voice channel mismatch for guild #{status.guild_id}, attempting to rejoin #{status.channel_id}"
    )

    attempt_voice_join(state, status.guild_id, status.channel_id, "rejoin")
  end

  defp attempt_voice_join(state, guild_id, channel_id, action) do
    case safe_join_voice_channel(guild_id, channel_id) do
      :ok ->
        state

      {:error, reason} ->
        Logger.error("Failed to #{action} voice channel: #{inspect(reason)}")
        %{state | voice_channel: nil}
    end
  end

  defp safe_voice_ready(guild_id) do
    {:ok, Voice.ready?(guild_id)}
  rescue
    error -> {:error, {:voice_not_ready, Exception.message(error)}}
  end

  defp safe_voice_playing(guild_id) do
    {:ok, Voice.playing?(guild_id)}
  rescue
    error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}
  end

  defp safe_join_voice_channel(guild_id, channel_id) do
    Voice.join_channel(guild_id, channel_id)
    :ok
  rescue
    error -> {:error, {:voice_join_failed, Exception.message(error)}}
  catch
    :exit, reason -> {:error, {:voice_join_failed, reason}}
  end
end


================================================
FILE: lib/soundboard/audio_player.ex
================================================
defmodule Soundboard.AudioPlayer do
  @moduledoc """
  Handles audio playback coordination.
  """

  use GenServer

  require Logger

  alias Soundboard.Accounts.User
  alias Soundboard.AudioPlayer.{Notifier, PlaybackQueue, SoundLibrary, VoiceSession}
  alias Soundboard.Discord.Handler.{AutoJoinPolicy, IdleTimeoutPolicy, VoicePresence}
  alias Soundboard.Discord.Voice

  @interrupt_watchdog_ms 35
  @interrupt_watchdog_max_attempts 20

  defmodule State do
    @moduledoc """
    The state of the audio player.
    """

    defstruct [
      :voice_channel,
      :current_playback,
      :pending_request,
      :interrupting,
      :interrupt_watchdog_ref,
      :interrupt_watchdog_attempt,
      :idle_timeout_ref
    ]

    @type t :: %__MODULE__{
            voice_channel: {String.t(), String.t()} | nil,
            current_playback: map() | nil,
            pending_request: map() | nil,
            interrupting: boolean() | nil,
            interrupt_watchdog_ref: reference() | nil,
            interrupt_watchdog_attempt: non_neg_integer() | nil,
            idle_timeout_ref: {reference(), reference()} | nil
          }
  end

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %State{}, name: __MODULE__)
  end

  def play_sound(sound_name, actor) do
    GenServer.cast(__MODULE__, {:play_sound, sound_name, actor})
  end

  def stop_sound do
    GenServer.cast(__MODULE__, :stop_sound)
  end

  def set_voice_channel(guild_id, channel_id) do
    GenServer.cast(__MODULE__, {:set_voice_channel, guild_id, channel_id})
  end

  def last_user_left(guild_id) do
    GenServer.cast(__MODULE__, {:last_user_left, guild_id})
  end

  def user_joined_channel(guild_id) do
    GenServer.cast(__MODULE__, {:user_joined_channel, guild_id})
  end

  def playback_finished(guild_id) do
    GenServer.cast(__MODULE__, {:playback_finished, guild_id})
  end

  def current_voice_channel do
    {:ok, GenServer.call(__MODULE__, :get_voice_channel)}
  rescue
    error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}
  catch
    :exit, reason -> {:error, {:voice_channel_unavailable, reason}}
  end

  @doc """
  Removes any cached metadata for the given `sound_name` so future plays use fresh data.
  """
  def invalidate_cache(sound_name), do: SoundLibrary.invalidate_cache(sound_name)

  @impl true
  def init(state) do
    SoundLibrary.ensure_cache()
    schedule_voice_check()

    {:ok,
     %{
       state
       | current_playback: nil,
         pending_request: nil,
         interrupting: false,
         interrupt_watchdog_ref: nil,
         interrupt_watchdog_attempt: 0,
         idle_timeout_ref: nil
     }}
  end

  @impl true
  def handle_cast({:set_voice_channel, guild_id, channel_id}, state) do
    next_state =
      case VoiceSession.normalize_channel(guild_id, channel_id) do
        nil ->
          state
          |> PlaybackQueue.clear_all()
          |> cancel_idle_timeout()
          |> Map.put(:voice_channel, nil)

        voice_channel ->
          new_state =
            state
            |> cancel_idle_timeout()
            |> Map.put(:voice_channel, voice_channel)

          if AutoJoinPolicy.mode() == :play, do: schedule_idle_timeout(new_state), else: new_state
      end

    {:noreply, next_state}
  end

  def handle_cast(:stop_sound, %{voice_channel: {guild_id, _channel_id}} = state) do
    Voice.stop(guild_id)
    Notifier.sound_played("All sounds stopped", "System")

    {:noreply, PlaybackQueue.clear_all(state)}
  end

  def handle_cast(:stop_sound, state) do
    Notifier.error("Bot is not connected to a voice channel")
    {:noreply, state}
  end

  def handle_cast({:playback_finished, guild_id}, state) do
    {:noreply, PlaybackQueue.handle_playback_finished(state, guild_id)}
  end

  def handle_cast({:play_sound, sound_name, actor}, %{voice_channel: nil} = state) do
    if AutoJoinPolicy.mode() == :play do
      case try_auto_join(actor) do
        {:ok, {guild_id, channel_id}} ->
          new_state =
            state
            |> Map.put(:voice_channel, {guild_id, channel_id})
            |> schedule_idle_timeout()

          do_play_sound(sound_name, actor, new_state)

        :not_found ->
          Notifier.error("Bot is not connected to a voice channel. Use !join in Discord first.")
          {:noreply, state}
      end
    else
      Notifier.error("Bot is not connected to a voice channel. Use !join in Discord first.")
      {:noreply, state}
    end
  end

  def handle_cast({:play_sound, sound_name, actor}, state) do
    do_play_sound(sound_name, actor, state)
  end

  def handle_cast({:last_user_left, guild_id}, %{voice_channel: {guild_id, _}} = state) do
    case AutoJoinPolicy.mode() do
      mode when mode in [:presence, :play] ->
        Logger.info("Last user left (#{mode} mode); leaving guild #{guild_id}")
        safely_leave(guild_id)

        new_state =
          state
          |> cancel_idle_timeout()
          |> PlaybackQueue.clear_all()
          |> Map.put(:voice_channel, nil)

        {:noreply, new_state}

      false ->
        Logger.info("Last user left (false mode); starting idle timer")
        {:noreply, reset_idle_timeout(state)}
    end
  end

  def handle_cast({:last_user_left, _guild_id}, state), do: {:noreply, state}

  def handle_cast({:user_joined_channel, _guild_id}, state) do
    {:noreply, cancel_idle_timeout(state)}
  end

  @impl true
  def handle_call(:get_voice_channel, _from, state) do
    {:reply, state.voice_channel, state}
  end

  @impl true
  def handle_info(
        {:idle_timeout, token},
        %{idle_timeout_ref: {_ref, token}, voice_channel: {guild_id, _}} = state
      ) do
    Logger.info("Voice idle timeout in guild #{guild_id}; leaving channel")
    safely_leave(guild_id)

    new_state =
      %{state | idle_timeout_ref: nil}
      |> PlaybackQueue.clear_all()
      |> Map.put(:voice_channel, nil)

    {:noreply, new_state}
  end

  def handle_info({:idle_timeout, _stale_token}, state), do: {:noreply, state}

  @impl true
  def handle_info(:check_voice_connection, state) do
    schedule_voice_check()
    {:noreply, VoiceSession.maintain_connection(state)}
  end

  @impl true
  def handle_info({ref, result}, %{current_playback: %{task_ref: ref}} = state) do
    Process.demonitor(ref, [:flush])
    {:noreply, PlaybackQueue.handle_task_result(state, result)}
  end

  @impl true
  def handle_info(
        {:DOWN, ref, :process, _pid, reason},
        %{current_playback: %{task_ref: ref}} = state
      ) do
    {:noreply, PlaybackQueue.handle_task_down(state, reason)}
  end

  @impl true
  def handle_info({:interrupt_watchdog, guild_id, attempt}, state) do
    {:noreply,
     PlaybackQueue.handle_interrupt_watchdog(
       state,
       guild_id,
       attempt,
       @interrupt_watchdog_max_attempts,
       @interrupt_watchdog_ms
     )}
  end

  @impl true
  def handle_info(_, state), do: {:noreply, state}

  defp do_play_sound(sound_name, actor, %{voice_channel: voice_channel} = state) do
    case PlaybackQueue.build_request(voice_channel, sound_name, actor) do
      {:ok, request} ->
        new_state =
          if AutoJoinPolicy.mode() == :play, do: reset_idle_timeout(state), else: state

        {:noreply, PlaybackQueue.enqueue(new_state, request, @interrupt_watchdog_ms)}

      {:error, reason} ->
        Notifier.error(reason)
        {:noreply, state}
    end
  end

  defp try_auto_join(actor) do
    case actor_discord_id(actor) do
      nil -> :not_found
      discord_id -> find_and_join_voice(discord_id)
    end
  end

  defp find_and_join_voice(discord_id) do
    case VoicePresence.find_user_voice_channel(discord_id) do
      {:ok, {guild_id, channel_id}} ->
        Logger.info(
          "Auto-joining channel #{channel_id} in guild #{guild_id} for user #{discord_id}"
        )

        Voice.join_channel(guild_id, channel_id)
        {:ok, {guild_id, channel_id}}

      :not_found ->
        Logger.info("User #{discord_id} not in a voice channel; skipping auto-join")
        :not_found
    end
  rescue
    error ->
      Logger.warning("Auto-join failed: #{inspect(error)}")
      :not_found
  end

  defp safely_leave(guild_id) do
    Voice.leave_channel(guild_id)
  rescue
    error -> Logger.warning("Voice leave failed: #{inspect(error)}")
  end

  defp actor_discord_id(%User{discord_id: id}) when is_binary(id) and id != "", do: id
  defp actor_discord_id(%{discord_id: id}) when is_binary(id) and id != "", do: id
  defp actor_discord_id(_), do: nil

  defp schedule_idle_timeout(state) do
    case IdleTimeoutPolicy.timeout_ms() do
      nil ->
        state

      ms ->
        token = make_ref()
        ref = Process.send_after(self(), {:idle_timeout, token}, ms)
        %{state | idle_timeout_ref: {ref, token}}
    end
  end

  defp cancel_idle_timeout(%{idle_timeout_ref: nil} = state), do: state

  defp cancel_idle_timeout(%{idle_timeout_ref: {ref, _token}} = state) do
    Process.cancel_timer(ref)
    %{state | idle_timeout_ref: nil}
  end

  defp reset_idle_timeout(state) do
    state |> cancel_idle_timeout() |> schedule_idle_timeout()
  end

  defp schedule_voice_check do
    if Application.get_env(:soundboard, __MODULE__, [])[:voice_maintenance_enabled] != false do
      Process.send_after(self(), :check_voice_connection, 30_000)
    end
  end
end


================================================
FILE: lib/soundboard/discord/bot_identity.ex
================================================
defmodule Soundboard.Discord.BotIdentity do
  @moduledoc false

  alias EDA.API.User
  alias EDA.Cache

  def fetch do
    case Cache.me() do
      nil -> fetch_from_api()
      user -> {:ok, normalize_user(user)}
    end
  end

  defp fetch_from_api do
    case User.me() do
      {:ok, user} ->
        Cache.put_me(user)
        {:ok, normalize_user(user)}

      other ->
        other
    end
  end

  defp normalize_user(%{id: id}), do: %{id: id}
  defp normalize_user(%{"id" => id}), do: %{id: id}
  defp normalize_user(_), do: %{}
end


================================================
FILE: lib/soundboard/discord/consumer.ex
================================================
defmodule Soundboard.Discord.Consumer do
  @moduledoc false
  @behaviour EDA.Consumer

  alias Soundboard.Discord.Handler

  @impl true
  def handle_event({event_name, payload}) do
    event = {event_name, payload, nil}
    Handler.dispatch_event(event)
  end
end


================================================
FILE: lib/soundboard/discord/guild_cache.ex
================================================
defmodule Soundboard.Discord.GuildCache do
  @moduledoc false

  alias EDA.Cache

  def all do
    Cache.guilds()
    |> Enum.map(&normalize_guild/1)
  end

  def get(guild_id) do
    case Cache.get_guild(to_id(guild_id)) do
      nil -> :error
      guild -> {:ok, normalize_guild(guild)}
    end
  end

  def get!(guild_id) do
    case get(guild_id) do
      {:ok, guild} -> guild
      _ -> raise "guild #{guild_id} not found in cache"
    end
  end

  defp normalize_guild(guild) do
    guild_id = map_get(guild, "id")
    channels = Cache.channels_for_guild(guild_id)
    voice_states = Cache.voice_states(guild_id)

    %{
      id: guild_id,
      name: map_get(guild, "name"),
      channels: normalize_channels(channels, guild_id),
      voice_states: Enum.map(voice_states, &normalize_voice_state(&1, guild_id))
    }
  end

  defp normalize_channels(channels, guild_id) do
    Enum.reduce(channels, %{}, fn channel, acc ->
      channel_id = map_get(channel, "id")

      Map.put(acc, channel_id, %{
        id: channel_id,
        guild_id: guild_id,
        name: map_get(channel, "name")
      })
    end)
  end

  defp normalize_voice_state(voice_state, guild_id) do
    %{
      guild_id: guild_id,
      channel_id: map_get(voice_state, "channel_id"),
      user_id: map_get(voice_state, "user_id"),
      session_id: map_get(voice_state, "session_id")
    }
  end

  defp map_get(map, key) when is_map(map) do
    case map do
      %{^key => value} ->
        value

      _ ->
        atom_key = String.to_atom(key)
        Map.get(map, atom_key)
    end
  end

  defp to_id(value) when is_integer(value), do: Integer.to_string(value)
  defp to_id(value), do: to_string(value)
end


================================================
FILE: lib/soundboard/discord/handler/auto_join_policy.ex
================================================
defmodule Soundboard.Discord.Handler.AutoJoinPolicy do
  @moduledoc false

  @type mode :: :presence | :play | false

  @spec mode() :: mode()
  def mode do
    case Application.get_env(:soundboard, :env) do
      :test -> :play
      _ -> parse_mode(System.get_env("AUTO_JOIN"))
    end
  end

  defp parse_mode(nil), do: :play

  defp parse_mode(value) do
    case value |> String.trim() |> String.downcase() do
      v when v in ["presence", "true", "1", "yes"] -> :presence
      "play" -> :play
      _ -> false
    end
  end
end


================================================
FILE: lib/soundboard/discord/handler/command_handler.ex
================================================
defmodule Soundboard.Discord.Handler.CommandHandler do
  @moduledoc false

  alias Soundboard.Discord.Handler.VoiceRuntime
  alias Soundboard.Discord.Message
  alias Soundboard.PublicURL

  def handle_message(%{content: "!join"} = msg) do
    case VoiceRuntime.user_voice_channel(msg.guild_id, msg.author.id) do
      nil ->
        Message.create(msg.channel_id, "You need to be in a voice channel!")

      channel_id ->
        VoiceRuntime.join_voice_channel(msg.guild_id, channel_id)
        Message.create(msg.channel_id, joined_message())
    end
  end

  def handle_message(%{content: "!leave", guild_id: guild_id, channel_id: channel_id})
      when not is_nil(guild_id) do
    VoiceRuntime.leave_voice_channel(guild_id)
    Message.create(channel_id, "Left the voice channel!")
  end

  def handle_message(_msg), do: :ignore

  defp joined_message do
    url = PublicURL.current()

    """
    Joined your voice channel!
    Access the soundboard here: #{url}
    """
  end
end


================================================
FILE: lib/soundboard/discord/handler/idle_timeout_policy.ex
================================================
defmodule Soundboard.Discord.Handler.IdleTimeoutPolicy do
  @moduledoc false

  require Logger

  @default_seconds 600

  @spec timeout_ms() :: pos_integer() | nil
  def timeout_ms do
    case raw_seconds() do
      n when n <= 0 -> nil
      n -> n * 1_000
    end
  end

  defp raw_seconds do
    case System.get_env("VOICE_IDLE_TIMEOUT_SECONDS") do
      nil ->
        @default_seconds

      raw ->
        case raw |> String.trim() |> Integer.parse() do
          {n, ""} ->
            n

          _ ->
            Logger.warning("Invalid VOICE_IDLE_TIMEOUT_SECONDS=#{inspect(raw)}; using default")
            @default_seconds
        end
    end
  end
end


================================================
FILE: lib/soundboard/discord/handler/sound_effects.ex
================================================
defmodule Soundboard.Discord.Handler.SoundEffects do
  @moduledoc false

  require Logger

  alias Soundboard.{AudioPlayer, Sounds}
  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceRuntime}

  def handle_join(user_id, previous_state, guild_id, channel_id) do
    is_join_event =
      case previous_state do
        nil -> true
        {nil, _} -> true
        {prev_channel, _} -> prev_channel != channel_id
      end

    Logger.info(
      "Join sound check - User: #{user_id}, Previous: #{inspect(previous_state)}, New channel: #{channel_id}, Is join: #{is_join_event}"
    )

    if is_join_event do
      play_join_sound(user_id, guild_id, channel_id)
    else
      :noop
    end
  end

  def handle_leave(user_id) do
    case Sounds.get_user_leave_sound_by_discord_id(user_id) do
      leave_sound when is_binary(leave_sound) ->
        Logger.info("Playing leave sound: #{leave_sound}")
        AudioPlayer.play_sound(leave_sound, "System")

      _ ->
        :noop
    end
  end

  defp play_join_sound(user_id, guild_id, channel_id) do
    join_sound = Sounds.get_user_join_sound_by_discord_id(user_id)

    Logger.info("Join sound query result for user #{user_id}: #{inspect(join_sound)}")

    case join_sound do
      join_sound when is_binary(join_sound) ->
        Logger.info("Playing join sound immediately: #{join_sound}")
        maybe_join_for_sound(guild_id, channel_id)
        AudioPlayer.play_sound(join_sound, "System")

      _ ->
        Logger.info("No join sound found for user #{user_id}")
        :noop
    end
  end

  defp maybe_join_for_sound(guild_id, channel_id) do
    if AutoJoinPolicy.mode() == :play && VoiceRuntime.get_current_voice_channel() == nil do
      Logger.info("Auto-joining #{guild_id}/#{channel_id} to play join sound")
      VoiceRuntime.join_voice_channel(guild_id, channel_id)
    end
  end
end


================================================
FILE: lib/soundboard/discord/handler/voice_commands.ex
================================================
defmodule Soundboard.Discord.Handler.VoiceCommands do
  @moduledoc false

  require Logger

  alias Soundboard.AudioPlayer
  alias Soundboard.Discord.{BotIdentity, Voice}

  def join_voice_channel(guild_id, channel_id) do
    execute(
      connected_to_discord?(),
      "Skipping join_voice_channel - not connected to Discord",
      fn ->
        Logger.info("Bot joining voice channel #{channel_id} in guild #{guild_id}")
        run("join voice channel", fn -> Voice.join_channel(guild_id, channel_id) end)
      end,
      fn -> AudioPlayer.set_voice_channel(guild_id, channel_id) end,
      fn error_msg -> Logger.error("Error joining voice channel: #{error_msg}") end
    )
  end

  def leave_voice_channel(guild_id) do
    execute(
      connected_to_discord?(),
      "Skipping leave_voice_channel - not connected to Discord",
      fn ->
        Logger.info("Bot leaving voice channel in guild #{guild_id}")
        run("leave voice channel", fn -> Voice.leave_channel(guild_id) end)
      end,
      fn -> AudioPlayer.set_voice_channel(nil, nil) end,
      fn error_msg -> Logger.error("Error leaving voice channel: #{error_msg}") end
    )
  end

  def connected_to_discord? do
    ready = :persistent_term.get(:soundboard_bot_ready, false)

    if ready do
      try do
        case BotIdentity.fetch() do
          {:ok, _} ->
            Logger.debug("Discord connection check: Connected and ready")
            true

          error ->
            Logger.debug("Discord connection check failed: #{inspect(error)}")
            false
        end
      rescue
        error ->
          Logger.debug("Discord connection check error: #{inspect(error)}")
          false
      end
    else
      Logger.debug("Discord connection check: Bot not ready (READY event not received)")
      false
    end
  end

  defp execute(true, _skip_message, command_fun, success_fun, error_fun) do
    case command_fun.() do
      :ok -> success_fun.()
      {:error, error_msg} -> error_fun.(error_msg)
    end
  end

  defp execute(false, skip_message, _command_fun, _success_fun, _error_fun) do
    Logger.warning(skip_message)
  end

  defp run(action, command) do
    case safely_run(command) do
      :ok ->
        :ok

      {:error, error_msg} ->
        if rate_limited?(error_msg) do
          Logger.warning("Rate limited while trying to #{action}, retrying in 5 seconds...")
          Process.sleep(5000)
          safely_run(command)
        else
          {:error, error_msg}
        end
    end
  end

  defp safely_run(command) do
    case command.() do
      :ok -> :ok
      other -> {:error, inspect(other)}
    end
  rescue
    error -> {:error, Exception.message(error)}
  end

  defp rate_limited?(error_msg) do
    is_binary(error_msg) and String.contains?(String.downcase(error_msg), "rate limit")
  end
end


================================================
FILE: lib/soundboard/discord/handler/voice_presence.ex
================================================
defmodule Soundboard.Discord.Handler.VoicePresence do
  @moduledoc false

  require Logger

  alias Soundboard.AudioPlayer
  alias Soundboard.Discord.{BotIdentity, GuildCache}

  def current_voice_channel do
    with {:ok, bot_id} <- bot_id() do
      case find_bot_voice_channel(bot_id) do
        nil -> :not_found
        channel -> {:ok, channel}
      end
    end
  end

  def user_voice_channel(guild_id, user_id) do
    case GuildCache.get(guild_id) do
      {:ok, guild} -> find_user_voice_channel(guild, user_id)
      :error -> {:error, {:guild_unavailable, guild_id}}
    end
  end

  def bot_user?(user_id) do
    case bot_id() do
      {:ok, bot_id} -> to_string(bot_id) == to_string(user_id)
      _ -> false
    end
  end

  def bot_id do
    case BotIdentity.fetch() do
      {:ok, %{id: id}} when not is_nil(id) -> {:ok, id}
      {:ok, _} -> {:error, :bot_identity_missing}
      {:error, reason} -> {:error, {:bot_identity_unavailable, reason}}
      other -> {:error, {:bot_identity_unavailable, other}}
    end
  end

  def cached_guilds do
    {:ok, GuildCache.all() |> Enum.to_list()}
  rescue
    error -> {:error, {:guild_cache_unavailable, Exception.message(error)}}
  end

  def find_user_voice_channel(discord_id) do
    case cached_guilds() do
      {:ok, guilds} ->
        Enum.find_value(guilds, :not_found, &find_in_guild(&1, discord_id))

      {:error, reason} ->
        Logger.debug("Guild cache unavailable for user voice channel lookup: #{inspect(reason)}")
        :not_found
    end
  end

  defp find_in_guild(guild, discord_id) do
    target = to_string(discord_id)

    case Enum.find(guild.voice_states, fn vs -> to_string(vs.user_id) == target end) do
      %{channel_id: channel_id} when not is_nil(channel_id) -> {:ok, {guild.id, channel_id}}
      _ -> nil
    end
  end

  def users_in_channel(guild_id, channel_id) do
    cond do
      not valid_discord_id?(guild_id) ->
        {:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}

      is_nil(channel_id) ->
        {:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}

      true ->
        count_users_in_channel(guild_id, channel_id)
    end
  end

  defp count_users_in_channel(guild_id, channel_id) do
    case GuildCache.get(guild_id) do
      {:ok, guild} ->
        bot_id = bot_id_value()
        voice_states = List.wrap(guild.voice_states)

        users_in_channel =
          voice_states
          |> Enum.count(fn vs -> vs.channel_id == channel_id && vs.user_id != bot_id end)

        log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states)
        {:ok, users_in_channel}

      :error ->
        {:error, {:guild_unavailable, guild_id}}
    end
  end

  defp bot_id_value do
    case bot_id() do
      {:ok, id} -> id
      _ -> nil
    end
  end

  defp log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states) do
    Logger.info("""
    Voice state check:
    Channel ID: #{channel_id}
    Users in channel: #{users_in_channel} (excluding bot)
    Bot ID: #{bot_id}
    Voice states: #{inspect(voice_states)}
    """)
  end

  defp find_bot_voice_channel(bot_id) do
    case cached_guilds() do
      {:ok, []} ->
        fallback_voice_channel()

      {:ok, guilds} ->
        find_voice_channel_in_guilds(guilds, bot_id) || fallback_voice_channel()

      {:error, reason} ->
        Logger.debug("Guild cache unavailable for bot voice channel lookup: #{inspect(reason)}")
        fallback_voice_channel()
    end
  end

  defp find_user_voice_channel(guild, user_id) do
    case Enum.find(guild.voice_states, fn vs -> vs.user_id == user_id end) do
      nil -> :not_found
      voice_state -> {:ok, voice_state.channel_id}
    end
  end

  defp find_voice_channel_in_guilds(guilds, bot_id) do
    Enum.find_value(guilds, &voice_channel_for_guild(&1, bot_id))
  end

  defp voice_channel_for_guild(guild, bot_id) do
    guild.voice_states
    |> List.wrap()
    |> Enum.find_value(fn
      %{user_id: ^bot_id, channel_id: channel_id} when not is_nil(channel_id) ->
        {guild.id, channel_id}

      _ ->
        nil
    end)
  end

  defp fallback_voice_channel do
    case AudioPlayer.current_voice_channel() do
      {:ok, {gid, cid}} when not is_nil(gid) and not is_nil(cid) ->
        {gid, cid}

      {:ok, _} ->
        nil

      {:error, reason} ->
        Logger.debug("Audio player voice channel unavailable: #{inspect(reason)}")
        nil
    end
  end

  defp valid_discord_id?(value), do: is_integer(value) or (is_binary(value) and value != "")
end


================================================
FILE: lib/soundboard/discord/handler/voice_runtime.ex
================================================
defmodule Soundboard.Discord.Handler.VoiceRuntime do
  @moduledoc false

  require Logger

  alias Soundboard.AudioPlayer
  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceCommands, VoicePresence}
  alias Soundboard.Discord.Voice

  @type runtime_action :: {:schedule_recheck_alone, String.t(), String.t(), non_neg_integer()}

  def bootstrap do
    Logger.info("Starting DiscordHandler...")
    if AutoJoinPolicy.mode() == :presence, do: start_guild_check_task()
    :ok
  end

  def join_voice_channel(guild_id, channel_id),
    do: VoiceCommands.join_voice_channel(guild_id, channel_id)

  def leave_voice_channel(guild_id), do: VoiceCommands.leave_voice_channel(guild_id)

  @spec handle_connect(map()) :: [runtime_action()]
  def handle_connect(payload) do
    case AutoJoinPolicy.mode() do
      :presence -> handle_auto_join_leave(payload)
      false -> handle_user_rejoin_cancel(payload)
      :play -> []
    end
  end

  @spec handle_disconnect(map()) :: [runtime_action()]
  def handle_disconnect(payload) do
    if bot_user?(payload.user_id) do
      []
    else
      handle_bot_alone_check(payload.guild_id)
    end
  end

  @spec recheck_alone(String.t(), String.t()) :: [runtime_action()]
  def recheck_alone(guild_id, channel_id) do
    case current_voice_channel_status() do
      {:ok, {^guild_id, ^channel_id}} -> handle_recheck_alone(guild_id, channel_id)
      _ -> Logger.debug("Recheck skipped; voice target changed")
    end

    []
  end

  def get_current_voice_channel do
    case current_voice_channel_status() do
      {:ok, channel} -> channel
      _ -> nil
    end
  end

  def user_voice_channel(guild_id, user_id) do
    case VoicePresence.user_voice_channel(guild_id, user_id) do
      {:ok, channel_id} -> channel_id
      _ -> nil
    end
  end

  def bot_user?(user_id), do: VoicePresence.bot_user?(user_id)

  defp start_guild_check_task do
    Task.start(fn ->
      Logger.info("Starting voice channel check task...")
      Process.sleep(5000)
      check_guilds()
    end)
  end

  defp check_guilds do
    case VoicePresence.cached_guilds() do
      {:ok, []} ->
        Logger.warning("No guilds found in cache. Discord may not be ready.")

      {:ok, guilds} ->
        process_guilds(guilds)

      {:error, reason} ->
        Logger.warning("Guild cache unavailable during bootstrap: #{inspect(reason)}")
    end
  end

  defp process_guilds(guilds) do
    Logger.info("Checking #{length(guilds)} guilds for active voice channels")
    Enum.each(guilds, &check_and_join_voice/1)
  end

  defp check_and_join_voice(guild) do
    voice_states = guild.voice_states
    bot_id = current_bot_id()

    case Enum.find(voice_states, fn vs -> vs.user_id != bot_id && vs.channel_id != nil end) do
      %{channel_id: channel_id} ->
        Logger.info("Auto-joining guild #{guild.id} channel #{channel_id} during bootstrap")
        Voice.join_channel(guild.id, channel_id)
        AudioPlayer.set_voice_channel(guild.id, channel_id)

      _ ->
        :ok
    end
  end

  defp handle_recheck_alone(guild_id, channel_id) do
    case VoicePresence.users_in_channel(guild_id, channel_id) do
      {:ok, users} ->
        Logger.info("Recheck alone: channel #{channel_id} now has #{users} non-bot users")
        maybe_act_if_bot_alone(guild_id, channel_id, users)

      {:error, reason} ->
        Logger.warning("Recheck skipped because voice state was unavailable: #{inspect(reason)}")
    end
  end

  defp maybe_act_if_bot_alone(guild_id, _channel_id, 0) do
    Logger.info("Recheck confirms bot is alone; leaving channel")
    bot_alone_action(guild_id)
  end

  defp maybe_act_if_bot_alone(_guild_id, _channel_id, _users), do: :ok

  defp handle_bot_alone_check(_guild_id) do
    case current_voice_channel_status() do
      {:ok, {guild_id, channel_id}} -> check_and_maybe_act(guild_id, channel_id)
      _ -> []
    end
  end

  defp check_and_maybe_act(guild_id, channel_id) do
    case VoicePresence.users_in_channel(guild_id, channel_id) do
      {:ok, 0} ->
        Logger.info("No non-bot users remaining in channel, acting on bot alone")
        bot_alone_action(guild_id)
        []

      {:ok, users} ->
        Logger.info("Non-bot users detected (#{users}); scheduling recheck in 1.5s")
        [schedule_recheck(guild_id, channel_id)]

      {:error, reason} ->
        Logger.warning(
          "Skipping leave check because voice state was unavailable: #{inspect(reason)}"
        )

        []
    end
  end

  defp bot_alone_action(guild_id) do
    case AutoJoinPolicy.mode() do
      :presence -> leave_voice_channel(guild_id)
      _ -> AudioPlayer.last_user_left(guild_id)
    end
  end

  defp handle_auto_join_leave(payload) do
    if bot_user?(payload.user_id) do
      Logger.debug("Ignoring bot's own voice state update in auto-join logic")
      []
    else
      process_user_voice_update(payload)
    end
  end

  defp handle_user_rejoin_cancel(payload) do
    if bot_user?(payload.user_id) do
      []
    else
      case current_voice_channel_status() do
        {:ok, {guild_id, channel_id}}
        when guild_id == payload.guild_id and channel_id == payload.channel_id ->
          Logger.debug("User rejoined bot's channel (false mode); cancelling idle timer")
          AudioPlayer.user_joined_channel(guild_id)
          []

        _ ->
          []
      end
    end
  end

  defp process_user_voice_update(payload) do
    case current_voice_channel_status() do
      :not_found when payload.channel_id != nil ->
        handle_bot_not_in_voice(payload)

      {:ok, {guild_id, current_channel_id}} when current_channel_id != payload.channel_id ->
        handle_bot_in_different_channel(guild_id, current_channel_id)

      _ ->
        Logger.debug("No action needed for voice state update")
        []
    end
  end

  defp handle_bot_not_in_voice(payload) do
    case VoicePresence.users_in_channel(payload.guild_id, payload.channel_id) do
      {:ok, users_in_channel} ->
        Logger.info("Found #{users_in_channel} users in channel #{payload.channel_id}")
        maybe_join_channel_for_payload(payload, users_in_channel)
        []

      {:error, reason} ->
        Logger.warning(
          "Skipping auto-join because voice state was unavailable: #{inspect(reason)}"
        )

        []
    end
  end

  defp handle_bot_in_different_channel(guild_id, current_channel_id) do
    case VoicePresence.users_in_channel(guild_id, current_channel_id) do
      {:ok, users} ->
        Logger.info("Current channel #{current_channel_id} has #{users} users")
        handle_current_channel_users(guild_id, current_channel_id, users)

      {:error, reason} ->
        Logger.warning(
          "Skipping channel switch handling because voice state was unavailable: #{inspect(reason)}"
        )

        []
    end
  end

  defp maybe_join_channel_for_payload(_payload, users_in_channel) when users_in_channel <= 0,
    do: :ok

  defp maybe_join_channel_for_payload(payload, users_in_channel) do
    if Voice.ready?(payload.guild_id) do
      Logger.debug("Bot already connected to voice in guild #{payload.guild_id}, skipping join")
    else
      Logger.info("Joining channel #{payload.channel_id} with #{users_in_channel} users")
      join_voice_channel(payload.guild_id, payload.channel_id)
    end
  end

  defp handle_current_channel_users(guild_id, current_channel_id, 0) do
    Logger.info("Bot is alone in channel #{current_channel_id}, leaving")
    leave_voice_channel(guild_id)
    []
  end

  defp handle_current_channel_users(guild_id, current_channel_id, _users) do
    [schedule_recheck(guild_id, current_channel_id)]
  end

  defp schedule_recheck(guild_id, channel_id),
    do: {:schedule_recheck_alone, guild_id, channel_id, 1_500}

  defp current_voice_channel_status do
    case VoicePresence.current_voice_channel() do
      {:ok, channel} ->
        {:ok, channel}

      :not_found ->
        :not_found

      {:error, reason} ->
        Logger.debug("Current voice channel unavailable: #{inspect(reason)}")
        :not_found
    end
  end

  defp current_bot_id do
    case VoicePresence.bot_id() do
      {:ok, bot_id} -> bot_id
      _ -> nil
    end
  end
end


================================================
FILE: lib/soundboard/discord/handler.ex
================================================
defmodule Soundboard.Discord.Handler do
  @moduledoc """
  Handles the Discord events.
  """
  use GenServer
  require Logger

  alias Soundboard.Discord.Handler.{CommandHandler, SoundEffects, VoiceRuntime}

  defmodule State do
    @moduledoc """
    Handles the state of the Discord handler.
    """
    use GenServer

    def start_link(_) do
      GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
    end

    def init(_) do
      {:ok, %{voice_states: %{}}}
    end

    def get_state(user_id) do
      GenServer.call(__MODULE__, {:get_state, user_id})
    catch
      :exit, _ -> nil
    end

    def update_state(user_id, channel_id, session_id) do
      GenServer.cast(__MODULE__, {:update_state, user_id, channel_id, session_id})
    catch
      :exit, _ -> :error
    end

    def handle_call({:get_state, user_id}, _from, state) do
      {:reply, Map.get(state.voice_states, user_id), state}
    end

    def handle_cast({:update_state, user_id, channel_id, session_id}, state) do
      {:noreply,
       %{state | voice_states: Map.put(state.voice_states, user_id, {channel_id, session_id})}}
    end
  end

  def init do
    VoiceRuntime.bootstrap()
  end

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def dispatch_event(event) do
    case Process.whereis(__MODULE__) do
      nil ->
        Logger.warning("DiscordHandler is not running; dropping event #{inspect(elem(event, 0))}")
        :error

      _pid ->
        GenServer.cast(__MODULE__, {:eda_event, event})
        :ok
    end
  end

  @impl GenServer
  def init([]) do
    init()
    {:ok, nil}
  end

  def handle_event({:VOICE_STATE_UPDATE, %{channel_id: nil} = payload, _ws_state}) do
    Logger.info("User #{payload.user_id} disconnected from voice")
    State.update_state(payload.user_id, nil, payload.session_id)

    if VoiceRuntime.bot_user?(payload.user_id) do
      Logger.debug("Skipping leave sound lookup for bot user #{payload.user_id}")
    else
      SoundEffects.handle_leave(payload.user_id)
    end

    VoiceRuntime.handle_disconnect(payload)
  end

  def handle_event({:VOICE_STATE_UPDATE, payload, _ws_state}) do
    Logger.info("Voice state update received: #{inspect(payload)}")

    if VoiceRuntime.bot_user?(payload.user_id) do
      Logger.info(
        "BOT VOICE STATE UPDATE - Bot joined channel #{payload.channel_id} in guild #{payload.guild_id}"
      )
    end

    previous_state = State.get_state(payload.user_id)
    State.update_state(payload.user_id, payload.channel_id, payload.session_id)

    runtime_actions = VoiceRuntime.handle_connect(payload)

    if VoiceRuntime.bot_user?(payload.user_id) do
      Logger.debug("Skipping join sound lookup for bot user #{payload.user_id}")
    else
      SoundEffects.handle_join(
        payload.user_id,
        previous_state,
        payload.guild_id,
        payload.channel_id
      )
    end

    runtime_actions
  end

  def handle_event({:READY, _payload, _ws_state}) do
    Logger.info("Bot is READY - gateway connection established")
    :persistent_term.put(:soundboard_bot_ready, true)
    []
  end

  def handle_event({:VOICE_READY, payload, _ws_state}) do
    Logger.info("""
    Voice Ready Event:
    Guild ID: #{payload.guild_id}
    Channel ID: #{payload.channel_id}
    """)

    []
  end

  def handle_event({:VOICE_PLAYBACK_FINISHED, payload, _ws_state}) do
    Soundboard.AudioPlayer.playback_finished(payload.guild_id)
    []
  end

  def handle_event({:VOICE_SERVER_UPDATE, _payload, _ws_state}), do: []

  def handle_event({:VOICE_CHANNEL_STATUS_UPDATE, _payload, _ws_state}), do: []

  def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
    CommandHandler.handle_message(msg)
    []
  end

  def handle_event(_event), do: []

  @impl true
  def handle_cast({:eda_event, event}, state) do
    event
    |> handle_event()
    |> apply_runtime_actions()

    {:noreply, state}
  end

  @impl true
  def handle_info({:event, {event_name, payload, ws_state}}, state) do
    {event_name, payload, ws_state}
    |> handle_event()
    |> apply_runtime_actions()

    {:noreply, state}
  end

  def handle_info({:recheck_alone, guild_id, channel_id}, state) do
    guild_id
    |> VoiceRuntime.recheck_alone(channel_id)
    |> apply_runtime_actions()

    {:noreply, state}
  end

  def handle_info(_msg, state), do: {:noreply, state}

  def get_current_voice_channel do
    VoiceRuntime.get_current_voice_channel()
  end

  defp apply_runtime_actions(actions) when is_list(actions) do
    Enum.each(actions, &apply_runtime_action/1)
  end

  defp apply_runtime_actions(_actions), do: :ok

  defp apply_runtime_action({:schedule_recheck_alone, guild_id, channel_id, delay_ms}) do
    Process.send_after(self(), {:recheck_alone, guild_id, channel_id}, delay_ms)
  end

  defp apply_runtime_action(_action), do: :ok
end


================================================
FILE: lib/soundboard/discord/message.ex
================================================
defmodule Soundboard.Discord.Message do
  @moduledoc false

  alias EDA.API.Message, as: EDAMessage

  def create(channel_id, payload) do
    EDAMessage.create(to_id(channel_id), payload)
  end

  defp to_id(value) when is_integer(value), do: Integer.to_string(value)
  defp to_id(value), do: to_string(value)
end


================================================
FILE: lib/soundboard/discord/role_checker.ex
================================================
defmodule Soundboard.Discord.RoleChecker do
  @moduledoc false
  require Logger

  alias EDA.API.Member

  @doc """
  Check if the role-gated access feature is enabled.

  Returns true only when both required_guild_id and required_role_ids are configured.
  """
  def feature_enabled? do
    guild_id = Application.get_env(:soundboard, :required_guild_id)
    role_ids = Application.get_env(:soundboard, :required_role_ids, [])

    not is_nil(guild_id) and Enum.any?(role_ids)
  end

  @doc """
  Check if a user is authorized to access the application.

  Returns true if:
  - The feature is disabled, OR
  - The user's member object contains at least one of the required roles

  Returns false if:
  - The feature is enabled and the API call fails, OR
  - The user has none of the required roles, OR
  - The API response shape is unexpected
  """
  def authorized?(user_id) do
    if feature_enabled?() do
      check_member_roles(user_id)
    else
      true
    end
  end

  defp check_member_roles(user_id) do
    guild_id = Application.get_env(:soundboard, :required_guild_id)

    guild_id
    |> Member.get(user_id)
    |> member_authorized?(user_id)
  end

  defp member_authorized?({:ok, %{"roles" => roles}}, user_id) when is_list(roles) do
    required_role_ids = Application.get_env(:soundboard, :required_role_ids, [])
    authorized = Enum.any?(roles, &Enum.member?(required_role_ids, &1))

    unless authorized do
      Logger.info("Discord user #{user_id} has no matching required roles")
    end

    authorized
  end

  defp member_authorized?({:ok, _member}, user_id) do
    Logger.warning("Unexpected member response shape for Discord user #{user_id}")
    false
  end

  defp member_authorized?({:error, reason}, user_id) do
    Logger.error("Member API error for Discord user #{user_id}: #{inspect(reason)}")
    false
  end
end


================================================
FILE: lib/soundboard/discord/runtime_capability.ex
================================================
defmodule Soundboard.Discord.RuntimeCapability do
  @moduledoc false

  require Logger

  alias EDA.Voice.Dave.Native

  def discord_handler_enabled? do
    Application.get_env(:soundboard, :env) != :test and voice_runtime_available?()
  end

  def voice_runtime_available? do
    match?(:ok, voice_runtime_status())
  end

  def voice_runtime_status do
    cond do
      Application.get_env(:soundboard, :env) == :test ->
        :ok

      not Application.get_env(:eda, :dave, false) ->
        :ok

      Native.available?() ->
        :ok

      true ->
        {:degraded, :dave_unavailable}
    end
  end

  def log_degraded_mode do
    case voice_runtime_status() do
      {:degraded, :dave_unavailable} ->
        Logger.error("""
        Discord voice runtime is disabled because EDA DAVE is enabled but the native library is unavailable.
        The web app will continue to boot, but Discord voice features stay offline until DAVE is packaged correctly
        or EDA_DAVE=false is configured.
        """)

        :ok

      _ ->
        :ok
    end
  end
end


================================================
FILE: lib/soundboard/discord/voice.ex
================================================
defmodule Soundboard.Discord.Voice do
  @moduledoc false

  require Logger

  alias EDA.Voice, as: EDAVoice

  @connected_error "Must be connected to voice channel to play audio."
  @not_ready_error "Voice session is still negotiating encryption."
  @already_playing_error "Audio already playing in voice channel."

  def join_channel(guild_id, channel_id) do
    voice_module().join(to_id(guild_id), to_id(channel_id))
  end

  def leave_channel(guild_id) do
    voice_module().leave(to_id(guild_id))
  end

  def play(guild_id, input, type, opts \\ []) do
    guild_id = to_id(guild_id)

    case play_with_supported_arity(guild_id, input, type, opts) do
      :ok -> :ok
      {:error, :already_playing} -> {:error, @already_playing_error}
      {:error, :not_connected} -> {:error, @connected_error}
      {:error, :not_ready} -> {:error, @not_ready_error}
      {:error, reason} -> {:error, inspect(reason)}
    end
  end

  def stop(guild_id) do
    voice_module().stop(to_id(guild_id))
  end

  def ready?(guild_id) do
    voice_module().ready?(to_id(guild_id))
  end

  def channel_id(guild_id) do
    voice_module().channel_id(to_id(guild_id))
  end

  def playing?(guild_id) do
    voice_module().playing?(to_id(guild_id))
  end

  # Compatibility shape for existing RTP probe code.
  def get_voice(guild_id) do
    case voice_module().get_voice_state(to_id(guild_id)) do
      {:ok, %{sequence: seq} = state} -> {:ok, %{rtp_sequence: seq, state: state}}
      {:ok, state} -> {:ok, %{state: state}}
      {:error, reason} -> {:error, reason}
      other -> {:error, {:unexpected_voice_state, other}}
    end
  end

  defp play_with_supported_arity(guild_id, input, type, opts) do
    module = voice_module()

    cond do
      function_exported?(module, :play, 4) ->
        :erlang.apply(module, :play, [guild_id, input, type, opts])

      opts == [] ->
        module.play(guild_id, input, type)

      true ->
        Logger.debug("EDA.Voice.play/4 unavailable; dropping playback opts #{inspect(opts)}")
        module.play(guild_id, input, type)
    end
  end

  defp voice_module do
    Application.get_env(:soundboard, :eda_voice_module, EDAVoice)
  end

  defp to_id(nil), do: nil
  defp to_id(value) when is_integer(value), do: Integer.to_string(value)
  defp to_id(value), do: to_string(value)
end


================================================
FILE: lib/soundboard/favorites/favorite.ex
================================================
defmodule Soundboard.Favorites.Favorite do
  @moduledoc """
  The Favorite module.
  """
  use Ecto.Schema
  import Ecto.Changeset

  alias Soundboard.Accounts.User
  alias Soundboard.Sound

  schema "favorites" do
    belongs_to :user, User
    belongs_to :sound, Sound

    timestamps()
  end

  def changeset(favorite, attrs) do
    favorite
    |> cast(attrs, [:user_id, :sound_id])
    |> validate_required([:user_id, :sound_id])
    |> foreign_key_constraint(:user_id)
    |> foreign_key_constraint(:sound_id)
    |> unique_constraint([:user_id, :sound_id])
  end
end


================================================
FILE: lib/soundboard/favorites.ex
================================================
defmodule Soundboard.Favorites do
  @moduledoc """
  The Favorites module.
  """

  import Ecto.Query

  alias Soundboard.{Favorites.Favorite, Repo, Sound}

  @type favorite_result :: {:ok, Favorite.t()} | {:error, Ecto.Changeset.t()}

  @max_favorites 16

  @spec list_favorites(integer()) :: [integer()]
  def list_favorites(user_id) do
    Favorite
    |> where([f], f.user_id == ^user_id)
    |> select([f], f.sound_id)
    |> Repo.all()
  end

  @spec list_favorite_sounds_with_tags(integer()) :: [Sound.t()]
  def list_favorite_sounds_with_tags(user_id) do
    favorite_ids_query =
      Favorite
      |> where([f], f.user_id == ^user_id)
      |> select([f], f.sound_id)

    Sound.with_tags()
    |> where([s], s.id in subquery(favorite_ids_query))
    |> order_by([s], asc: fragment("lower(?)", s.filename))
    |> Repo.all()
  end

  @spec toggle_favorite(integer(), integer()) :: favorite_result()
  def toggle_favorite(user_id, sound_id) do
    case Repo.get_by(Favorite, user_id: user_id, sound_id: sound_id) do
      nil -> add_favorite(user_id, sound_id)
      favorite -> Repo.delete(favorite)
    end
  end

  @spec error_message(Ecto.Changeset.t()) :: String.t()
  def error_message(%Ecto.Changeset{} = changeset) do
    Enum.map_join(changeset.errors, ", ", fn
      {:base, {msg, _}} -> msg
      {:sound, {"does not exist", _}} -> "Sound does not exist"
      {field, {msg, _}} -> "#{field} #{msg}"
    end)
  end

  defp add_favorite(user_id, sound_id) do
    case Repo.get(Sound, sound_id) do
      nil ->
        {:error,
         Ecto.Changeset.add_error(Ecto.Changeset.change(%Favorite{}), :sound, "does not exist")}

      _sound ->
        # Check if user has reached max favorites
        count = Repo.one(from f in Favorite, where: f.user_id == ^user_id, select: count())

        if count >= @max_favorites do
          {:error,
           Ecto.Changeset.add_error(
             Ecto.Changeset.change(%Favorite{}),
             :base,
             "You can only have #{@max_favorites} favorites"
           )}
        else
          %Favorite{}
          |> Favorite.changeset(%{user_id: user_id, sound_id: sound_id})
          |> Repo.insert()
        end
    end
  end

  @spec favorite?(integer(), integer()) :: boolean()
  def favorite?(user_id, sound_id) do
    Repo.exists?(from f in Favorite, where: f.user_id == ^user_id and f.sound_id == ^sound_id)
  end

  @spec max_favorites() :: pos_integer()
  def max_favorites, do: @max_favorites
end


================================================
FILE: lib/soundboard/public_url.ex
================================================
defmodule Soundboard.PublicURL do
  @moduledoc """
  Shared helper for the application's externally visible base URL.

  Web and Discord-facing features use this so URL generation follows one
  application-level contract instead of reaching into endpoint config details in
  multiple places.
  """

  def current, do: SoundboardWeb.Endpoint.url()

  def from_uri_or_current(nil), do: current()

  def from_uri_or_current(uri) do
    case URI.parse(uri) do
      %URI{scheme: scheme, host: host, port: port} when is_binary(scheme) and is_binary(host) ->
        scheme <> "://" <> host <> port_suffix(scheme, port)

      _ ->
        current()
    end
  end

  defp port_suffix("http", 80), do: ""
  defp port_suffix("https", 443), do: ""
  defp port_suffix(_scheme, nil), do: ""
  defp port_suffix(_scheme, port), do: ":#{port}"
end


================================================
FILE: lib/soundboard/pubsub_topics.ex
================================================
defmodule Soundboard.PubSubTopics do
  @moduledoc false

  alias Phoenix.PubSub

  @files_topic "soundboard.files"
  @playback_topic "soundboard.playback"
  @stats_topic "soundboard.stats"

  def files_topic, do: @files_topic
  def playback_topic, do: @playback_topic
  def stats_topic, do: @stats_topic

  def subscribe_files, do: PubSub.subscribe(Soundboard.PubSub, @files_topic)
  def subscribe_playback, do: PubSub.subscribe(Soundboard.PubSub, @playback_topic)
  def subscribe_stats, do: PubSub.subscribe(Soundboard.PubSub, @stats_topic)

  def broadcast_files_updated do
    PubSub.broadcast(Soundboard.PubSub, @files_topic, {:files_updated})
  end

  def broadcast_stats_updated do
    PubSub.broadcast(Soundboard.PubSub, @stats_topic, {:stats_updated})
  end

  def broadcast_sound_played(sound_name, username) do
    PubSub.broadcast(
      Soundboard.PubSub,
      @
Download .txt
gitextract__nmkll40/

├── .credo.exs
├── .dockerignore
├── .ex_dna.exs
├── .formatter.exs
├── .github/
│   ├── dependabot.yml
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .tool-versions
├── AGENTS.md
├── Dockerfile
├── README.md
├── assets/
│   ├── css/
│   │   └── app.css
│   ├── js/
│   │   ├── app.js
│   │   └── hooks/
│   │       └── local_player.js
│   ├── tailwind.config.js
│   └── vendor/
│       └── topbar.js
├── config/
│   ├── config.exs
│   ├── dev.exs
│   ├── prod.exs
│   ├── runtime.exs
│   └── test.exs
├── coveralls.json
├── docker-compose.yml
├── docs/
│   ├── plans/
│   │   ├── discord-role-gated-access.md
│   │   └── discord-role-gated-access.md.tasks.json
│   └── specs/
│       ├── discord-role-gated-access.md
│       ├── voice-auto-join-idle-leave.md
│       └── youtube-playback.md
├── entrypoint.sh
├── lib/
│   ├── soundboard/
│   │   ├── accounts/
│   │   │   ├── api_token.ex
│   │   │   ├── api_tokens.ex
│   │   │   └── user.ex
│   │   ├── accounts.ex
│   │   ├── application.ex
│   │   ├── audio_player/
│   │   │   ├── notifier.ex
│   │   │   ├── playback_engine.ex
│   │   │   ├── playback_queue.ex
│   │   │   ├── sound_library.ex
│   │   │   └── voice_session.ex
│   │   ├── audio_player.ex
│   │   ├── discord/
│   │   │   ├── bot_identity.ex
│   │   │   ├── consumer.ex
│   │   │   ├── guild_cache.ex
│   │   │   ├── handler/
│   │   │   │   ├── auto_join_policy.ex
│   │   │   │   ├── command_handler.ex
│   │   │   │   ├── idle_timeout_policy.ex
│   │   │   │   ├── sound_effects.ex
│   │   │   │   ├── voice_commands.ex
│   │   │   │   ├── voice_presence.ex
│   │   │   │   └── voice_runtime.ex
│   │   │   ├── handler.ex
│   │   │   ├── message.ex
│   │   │   ├── role_checker.ex
│   │   │   ├── runtime_capability.ex
│   │   │   └── voice.ex
│   │   ├── favorites/
│   │   │   └── favorite.ex
│   │   ├── favorites.ex
│   │   ├── public_url.ex
│   │   ├── pubsub_topics.ex
│   │   ├── release.ex
│   │   ├── repo.ex
│   │   ├── sound.ex
│   │   ├── sound_tag.ex
│   │   ├── sounds/
│   │   │   ├── management.ex
│   │   │   ├── tags.ex
│   │   │   ├── uploads/
│   │   │   │   ├── create_request.ex
│   │   │   │   ├── creator.ex
│   │   │   │   ├── normalizer.ex
│   │   │   │   └── source.ex
│   │   │   └── uploads.ex
│   │   ├── sounds.ex
│   │   ├── stats/
│   │   │   └── play.ex
│   │   ├── stats.ex
│   │   ├── tag.ex
│   │   ├── uploads_path.ex
│   │   ├── user_sound_setting.ex
│   │   └── volume.ex
│   ├── soundboard.ex
│   ├── soundboard_web/
│   │   ├── components/
│   │   │   ├── core_components.ex
│   │   │   ├── flash_component.ex
│   │   │   ├── layouts/
│   │   │   │   ├── app.html.heex
│   │   │   │   ├── navbar.ex
│   │   │   │   └── root.html.heex
│   │   │   ├── layouts.ex
│   │   │   └── soundboard/
│   │   │       ├── delete_modal.ex
│   │   │       ├── edit_modal.ex
│   │   │       ├── helpers.ex
│   │   │       ├── tag_components.ex
│   │   │       ├── upload_modal.ex
│   │   │       └── volume_control.ex
│   │   ├── controllers/
│   │   │   ├── api/
│   │   │   │   └── sound_controller.ex
│   │   │   ├── auth_controller.ex
│   │   │   ├── error_html.ex
│   │   │   ├── error_json.ex
│   │   │   └── upload_controller.ex
│   │   ├── endpoint.ex
│   │   ├── gettext.ex
│   │   ├── live/
│   │   │   ├── favorites_live.ex
│   │   │   ├── favorites_live.html.heex
│   │   │   ├── settings_live.ex
│   │   │   ├── soundboard_live/
│   │   │   │   ├── edit_flow.ex
│   │   │   │   └── upload_flow.ex
│   │   │   ├── soundboard_live.ex
│   │   │   ├── soundboard_live.html.heex
│   │   │   ├── stats_live.ex
│   │   │   └── support/
│   │   │       ├── flash_helpers.ex
│   │   │       ├── live_tags.ex
│   │   │       ├── presence_live.ex
│   │   │       ├── sound_playback.ex
│   │   │       └── tag_form.ex
│   │   ├── plugs/
│   │   │   ├── api_auth.ex
│   │   │   ├── basic_auth.ex
│   │   │   └── role_check.ex
│   │   ├── presence.ex
│   │   ├── presence_handler.ex
│   │   ├── router.ex
│   │   ├── sound_helpers.ex
│   │   ├── soundboard/
│   │   │   └── sound_filter.ex
│   │   └── telemetry.ex
│   └── soundboard_web.ex
├── mix.exs
├── priv/
│   ├── gettext/
│   │   ├── en/
│   │   │   └── LC_MESSAGES/
│   │   │       └── errors.po
│   │   └── errors.pot
│   ├── repo/
│   │   ├── migrations/
│   │   │   ├── .formatter.exs
│   │   │   ├── 20250101213201_create_sounds.exs
│   │   │   ├── 20250101213717_create_tags.exs
│   │   │   ├── 20250101231744_create_users.exs
│   │   │   ├── 20250102212120_create_plays.exs
│   │   │   ├── 20250102212121_create_favorites.exs
│   │   │   ├── 20250102212122_add_user_id_to_sounds.exs
│   │   │   ├── 20250102212123_change_favorites_filename_to_sound_id.exs
│   │   │   ├── 20250102212124_add_index_to_plays.exs
│   │   │   ├── 20250102212125_add_join_leave_flags_to_sounds.exs
│   │   │   ├── 20250102212126_add_url_to_sounds.exs
│   │   │   ├── 20250218214831_create_user_sound_settings.exs
│   │   │   ├── 20250218214832_remove_join_leave_flags_from_sounds.exs
│   │   │   ├── 20250218220000_create_api_tokens.exs
│   │   │   ├── 20250218223000_add_token_plain_to_api_tokens.exs
│   │   │   ├── 20250310120000_add_volume_to_sounds.exs
│   │   │   ├── 20260306150000_add_sound_id_to_plays.exs
│   │   │   ├── 20260306151000_finalize_favorites_and_sound_tags_migrations.exs
│   │   │   └── 20260307211000_rename_sound_name_to_played_filename_in_plays.exs
│   │   └── seeds.exs
│   └── static/
│       ├── manifest.json
│       └── robots.txt
└── test/
    ├── soundboard/
    │   ├── accounts/
    │   │   └── api_tokens_test.exs
    │   ├── accounts_test.exs
    │   ├── audio_player/
    │   │   ├── playback_engine_test.exs
    │   │   ├── playback_queue_test.exs
    │   │   └── sound_library_test.exs
    │   ├── discord/
    │   │   ├── bot_identity_test.exs
    │   │   ├── handler/
    │   │   │   ├── auto_join_policy_test.exs
    │   │   │   ├── command_handler_test.exs
    │   │   │   ├── idle_timeout_policy_test.exs
    │   │   │   ├── voice_presence_test.exs
    │   │   │   └── voice_runtime_test.exs
    │   │   ├── role_checker_test.exs
    │   │   ├── runtime_capability_test.exs
    │   │   └── voice_test.exs
    │   ├── favorites_test.exs
    │   ├── migrations/
    │   │   └── data_migrations_test.exs
    │   ├── public_url_test.exs
    │   ├── pubsub_topics_test.exs
    │   ├── sound_tag_test.exs
    │   ├── sound_test.exs
    │   ├── sounds/
    │   │   ├── management_test.exs
    │   │   ├── sound_settings_test.exs
    │   │   ├── tags_test.exs
    │   │   └── uploads_test.exs
    │   ├── stats_test.exs
    │   ├── tags/
    │   │   └── tag_test.exs
    │   ├── uploads_path_test.exs
    │   └── volume_test.exs
    ├── soundboard_test.exs
    ├── soundboard_web/
    │   ├── audio_player_test.exs
    │   ├── components/
    │   │   ├── layouts/
    │   │   │   └── navbar_test.exs
    │   │   └── soundboard/
    │   │       ├── edit_modal_test.exs
    │   │       └── upload_modal_test.exs
    │   ├── controllers/
    │   │   ├── api/
    │   │   │   └── sound_controller_test.exs
    │   │   ├── auth_controller_test.exs
    │   │   └── upload_controller_test.exs
    │   ├── discord_handler_test.exs
    │   ├── eda_consumer_test.exs
    │   ├── live/
    │   │   ├── favorites_live_test.exs
    │   │   ├── settings_live_test.exs
    │   │   ├── soundboard_live/
    │   │   │   ├── edit_flow_test.exs
    │   │   │   └── upload_flow_test.exs
    │   │   ├── soundboard_live_test.exs
    │   │   └── stats_live_test.exs
    │   ├── plugs/
    │   │   ├── api_auth_db_token_test.exs
    │   │   ├── basic_auth_test.exs
    │   │   └── role_check_test.exs
    │   ├── presence_handler_test.exs
    │   ├── sound_helpers_test.exs
    │   └── soundboard/
    │       └── sound_filter_test.exs
    ├── support/
    │   ├── conn_case.ex
    │   ├── data_case.ex
    │   └── test_helpers.ex
    └── test_helper.exs
Download .txt
SYMBOL INDEX (1000 symbols across 161 files)

FILE: assets/js/app.js
  constant MAX_VOLUME_PERCENT_DEFAULT (line 33) | const MAX_VOLUME_PERCENT_DEFAULT = 150
  constant BOOST_CAP (line 34) | const BOOST_CAP = 1.5
  method mounted (line 102) | mounted() {
  method updated (line 109) | updated() {
  method destroyed (line 114) | destroyed() {
  method readGain (line 118) | readGain() {
  method handleClick (line 122) | async handleClick(event) {
  method startPlayback (line 137) | async startPlayback() {
  method configureGain (line 170) | async configureGain(targetGain) {
  method releaseBoost (line 219) | releaseBoost() {
  method stopPlayback (line 230) | stopPlayback() {
  method setPlaying (line 244) | setPlaying(isPlaying) {
  method mounted (line 263) | mounted() {
  method updated (line 281) | updated() {
  method destroyed (line 295) | destroyed() {
  method syncDataset (line 326) | syncDataset() {
  method bindElements (line 338) | bindElements() {
  method initialPercent (line 368) | initialPercent() {
  method setPercent (line 373) | setPercent(percent, {emit = false} = {}) {
  method handleSliderInput (line 393) | async handleSliderInput(event) {
  method queuePush (line 399) | queuePush(percent) {
  method handlePreviewClick (line 417) | async handlePreviewClick(event) {
  method updatePreviewGain (line 459) | async updatePreviewGain(percent) {
  method ensurePreviewGraph (line 469) | async ensurePreviewGraph(targetGain) {
  method applyPreviewGain (line 510) | applyPreviewGain(targetGain) {
  method getPreviewSource (line 528) | getPreviewSource() {
  method stopPreview (line 563) | stopPreview(forceRevoke = false) {
  method setPreviewState (line 579) | setPreviewState(isPlaying) {
  method mounted (line 590) | mounted() {
  method destroyed (line 629) | destroyed() {

FILE: assets/js/hooks/local_player.js
  method mounted (line 5) | mounted() {
  method updateIcon (line 42) | updateIcon(isPlaying) {
  method playIcon (line 46) | playIcon() {
  method stopIcon (line 53) | stopIcon() {

FILE: lib/soundboard.ex
  class Soundboard (line 1) | defmodule Soundboard
    method app_name (line 13) | def app_name, do: :soundboard

FILE: lib/soundboard/accounts.ex
  class Soundboard.Accounts (line 1) | defmodule Soundboard.Accounts
    method get_user (line 10) | def get_user(user_id), do: Repo.get(User, user_id)
    method avatars_by_usernames (line 12) | def avatars_by_usernames([]), do: %{}

FILE: lib/soundboard/accounts/api_token.ex
  class Soundboard.Accounts.ApiToken (line 1) | defmodule Soundboard.Accounts.ApiToken
    method changeset (line 36) | def changeset(token, attrs) do

FILE: lib/soundboard/accounts/api_tokens.ex
  class Soundboard.Accounts.ApiTokens (line 1) | defmodule Soundboard.Accounts.ApiTokens
    method list_tokens (line 19) | def list_tokens(%User{id: user_id}) do
    method generate_token (line 29) | def generate_token(%User{id: user_id}, attrs \\ %{}) do
    method revoke_token (line 73) | def revoke_token(%User{id: user_id}, token_id) do
    method update_last_used_at (line 92) | defp update_last_used_at(%ApiToken{} = token) do
    method random_token (line 100) | defp random_token do
    method hash_token (line 104) | defp hash_token(raw) do

FILE: lib/soundboard/accounts/user.ex
  class Soundboard.Accounts.User (line 1) | defmodule Soundboard.Accounts.User
    method changeset (line 25) | def changeset(user, attrs) do

FILE: lib/soundboard/application.ex
  class Soundboard.Application (line 1) | defmodule Soundboard.Application
    method start (line 11) | def start(_type, _args) do
    method discord_children (line 30) | defp discord_children do
    method config_change (line 42) | def config_change(changed, _new, removed) do

FILE: lib/soundboard/audio_player.ex
  class Soundboard.AudioPlayer (line 1) | defmodule Soundboard.AudioPlayer
    method start_link (line 44) | def start_link(_opts) do
    method play_sound (line 48) | def play_sound(sound_name, actor) do
    method stop_sound (line 52) | def stop_sound do
    method set_voice_channel (line 56) | def set_voice_channel(guild_id, channel_id) do
    method last_user_left (line 60) | def last_user_left(guild_id) do
    method user_joined_channel (line 64) | def user_joined_channel(guild_id) do
    method playback_finished (line 68) | def playback_finished(guild_id) do
    method current_voice_channel (line 72) | def current_voice_channel do
    method invalidate_cache (line 83) | def invalidate_cache(sound_name), do: SoundLibrary.invalidate_cache(so...
    method init (line 86) | def init(state) do
    method handle_cast (line 103) | def handle_cast({:set_voice_channel, guild_id, channel_id}, state) do
    method handle_cast (line 124) | def handle_cast(:stop_sound, %{voice_channel: {guild_id, _channel_id}}...
    method handle_cast (line 131) | def handle_cast(:stop_sound, state) do
    method handle_cast (line 136) | def handle_cast({:playback_finished, guild_id}, state) do
    method handle_cast (line 140) | def handle_cast({:play_sound, sound_name, actor}, %{voice_channel: nil...
    method handle_cast (line 161) | def handle_cast({:play_sound, sound_name, actor}, state) do
    method handle_cast (line 165) | def handle_cast({:last_user_left, guild_id}, %{voice_channel: {guild_i...
    method handle_cast (line 185) | def handle_cast({:last_user_left, _guild_id}, state), do: {:noreply, s...
    method handle_cast (line 187) | def handle_cast({:user_joined_channel, _guild_id}, state) do
    method handle_call (line 192) | def handle_call(:get_voice_channel, _from, state) do
    method handle_info (line 197) | def handle_info(
    method handle_info (line 212) | def handle_info({:idle_timeout, _stale_token}, state), do: {:noreply, ...
    method handle_info (line 215) | def handle_info(:check_voice_connection, state) do
    method handle_info (line 221) | def handle_info({ref, result}, %{current_playback: %{task_ref: ref}} =...
    method handle_info (line 227) | def handle_info(
    method handle_info (line 235) | def handle_info({:interrupt_watchdog, guild_id, attempt}, state) do
    method handle_info (line 247) | def handle_info(_, state), do: {:noreply, state}
    method do_play_sound (line 249) | defp do_play_sound(sound_name, actor, %{voice_channel: voice_channel} ...
    method try_auto_join (line 263) | defp try_auto_join(actor) do
    method find_and_join_voice (line 270) | defp find_and_join_voice(discord_id) do
    method safely_leave (line 290) | defp safely_leave(guild_id) do
    method actor_discord_id (line 298) | defp actor_discord_id(_), do: nil
    method schedule_idle_timeout (line 300) | defp schedule_idle_timeout(state) do
    method cancel_idle_timeout (line 312) | defp cancel_idle_timeout(%{idle_timeout_ref: nil} = state), do: state
    method cancel_idle_timeout (line 314) | defp cancel_idle_timeout(%{idle_timeout_ref: {ref, _token}} = state) do
    method reset_idle_timeout (line 319) | defp reset_idle_timeout(state) do
    method schedule_voice_check (line 323) | defp schedule_voice_check do
  class State (line 18) | defmodule State

FILE: lib/soundboard/audio_player/notifier.ex
  class Soundboard.AudioPlayer.Notifier (line 1) | defmodule Soundboard.AudioPlayer.Notifier
    method sound_played (line 6) | def sound_played(sound_name, actor_name) do
    method error (line 10) | def error(message) do

FILE: lib/soundboard/audio_player/playback_engine.ex
  class Soundboard.AudioPlayer.PlaybackEngine (line 1) | defmodule Soundboard.AudioPlayer.PlaybackEngine
    method play (line 20) | def play(guild_id, channel_id, sound_name, path_or_url, volume, actor) do
    method maybe_settle_before_play (line 26) | defp maybe_settle_before_play({:joined, :ok}) do
    method maybe_settle_before_play (line 30) | defp maybe_settle_before_play(_), do: :ok
    method submit_play_request (line 32) | defp submit_play_request(guild_id, sound_name, path_or_url, volume, ac...
    method play_with_retries (line 72) | defp play_with_retries(%{sound_name: sound_name}, attempt, _refresh_at...
    method voice_play (line 78) | defp voice_play(play_request) do
    method classify_play_attempt (line 87) | defp classify_play_attempt(:ok), do: :ok
    method classify_play_attempt (line 89) | defp classify_play_attempt({:error, "Audio already playing in voice ch...
    method classify_play_attempt (line 99) | defp classify_play_attempt({:error, "Must be connected to voice channe...
    method classify_play_attempt (line 109) | defp classify_play_attempt({:error, "Voice session is still negotiatin...
    method classify_play_attempt (line 120) | defp classify_play_attempt({:error, reason}), do: {:error, reason}
    method classify_play_attempt (line 121) | defp classify_play_attempt(other), do: {:error, inspect(other)}
    method retry_play_attempt (line 123) | defp retry_play_attempt(play_request, attempt, refresh_attempted, retr...
    method maybe_trigger_rejoin (line 142) | defp maybe_trigger_rejoin(guild_id, attempt, refresh_attempted, force_...
    method maybe_rejoin_current_channel (line 151) | defp maybe_rejoin_current_channel(guild_id, force_refresh) do
    method maybe_rejoin_for_channel (line 167) | defp maybe_rejoin_for_channel(guild_id, channel_id, true) do
    method maybe_rejoin_for_channel (line 183) | defp maybe_rejoin_for_channel(guild_id, channel_id, false) do
    method refresh_voice_session (line 194) | defp refresh_voice_session(guild_id, channel_id) do
    method rejoin_voice_channel (line 202) | defp rejoin_voice_channel(guild_id, channel_id) do
    method reconnect_voice_session (line 206) | defp reconnect_voice_session(guild_id, channel_id, log_message) do
    method ensure_joined_channel (line 212) | defp ensure_joined_channel(guild_id, channel_id) do
    method maybe_probe_first_rtp (line 223) | defp maybe_probe_first_rtp(guild_id, sound_name, attempt_number) do
    method wait_for_first_rtp (line 250) | defp wait_for_first_rtp(
    method wait_for_voice_ready (line 301) | defp wait_for_voice_ready(guild_id, timeout_ms \\ @voice_ready_timeout...
    method do_wait_for_voice_ready (line 306) | defp do_wait_for_voice_ready(guild_id, started_at, timeout_ms) do
    method current_rtp_sequence (line 325) | defp current_rtp_sequence(guild_id) do
    method safe_voice_status (line 335) | defp safe_voice_status(guild_id) do
    method safe_voice_ready (line 342) | defp safe_voice_ready(guild_id) do
    method safe_voice_channel (line 348) | defp safe_voice_channel(guild_id) do
    method safe_voice_playing (line 354) | defp safe_voice_playing(guild_id) do
    method track_play_if_needed (line 360) | defp track_play_if_needed(sound_name, actor) do
    method broadcast_success (line 381) | defp broadcast_success(sound_name, actor) do
    method broadcast_error (line 385) | defp broadcast_error(message) do
    method unwrap_sequence (line 389) | defp unwrap_sequence({:ok, sequence}), do: sequence
    method unwrap_sequence (line 390) | defp unwrap_sequence({:error, _reason}), do: nil
    method ffmpeg_executable (line 392) | defp ffmpeg_executable do
    method clamp_volume (line 407) | defp clamp_volume(_), do: 1.0
    method actor_display_name (line 414) | defp actor_display_name(_), do: nil
    method actor_user_id (line 418) | defp actor_user_id(_), do: nil
    method system_user? (line 420) | defp system_user?(actor), do: actor_display_name(actor) in @system_users

FILE: lib/soundboard/audio_player/playback_queue.ex
  class Soundboard.AudioPlayer.PlaybackQueue (line 1) | defmodule Soundboard.AudioPlayer.PlaybackQueue
    method build_request (line 20) | def build_request({guild_id, channel_id}, sound_name, actor) do
    method enqueue (line 39) | def enqueue(%State{} = state, request, interrupt_watchdog_ms) do
    method clear_all (line 55) | def clear_all(%State{} = state) do
    method handle_task_result (line 66) | def handle_task_result(
    method handle_task_down (line 87) | def handle_task_down(%State{} = state, reason) do
    method handle_interrupt_watchdog (line 100) | def handle_interrupt_watchdog(
    method handle_interrupt_watchdog (line 133) | def handle_interrupt_watchdog(%State{} = state, _guild_id, _attempt, _...
    method handle_playback_finished (line 137) | def handle_playback_finished(%State{} = state, guild_id) do
    method start_playback (line 154) | defp start_playback(state, request) do
    method maybe_interrupt_current (line 173) | defp maybe_interrupt_current(%State{current_playback: %{guild_id: guil...
    method maybe_interrupt_current (line 190) | defp maybe_interrupt_current(%State{} = state, _delay_ms), do: state
    method maybe_start_pending (line 192) | defp maybe_start_pending(%State{pending_request: nil} = state), do: state
    method maybe_start_pending (line 194) | defp maybe_start_pending(%State{} = state) do
    method clear_current_playback (line 209) | defp clear_current_playback(%State{} = state) do
    method reset_interrupt_state (line 221) | defp reset_interrupt_state(%State{} = state) do
    method schedule_interrupt_watchdog (line 227) | defp schedule_interrupt_watchdog(%State{} = state, guild_id, attempt, ...
    method cancel_interrupt_watchdog (line 235) | defp cancel_interrupt_watchdog(%State{interrupt_watchdog_ref: nil} = s...
    method cancel_interrupt_watchdog (line 237) | defp cancel_interrupt_watchdog(%State{} = state) do
    method cancel_playback_task (line 242) | defp cancel_playback_task(nil), do: :ok
    method cancel_playback_task (line 254) | defp cancel_playback_task(_), do: :ok
    method safe_voice_playing (line 256) | defp safe_voice_playing(guild_id) do

FILE: lib/soundboard/audio_player/sound_library.ex
  class Soundboard.AudioPlayer.SoundLibrary (line 1) | defmodule Soundboard.AudioPlayer.SoundLibrary
    method ensure_cache (line 8) | def ensure_cache do
    method get_sound_path (line 19) | def get_sound_path(sound_name) do
    method prepare_play_input (line 28) | def prepare_play_input(sound_name, path_or_url) do
    method invalidate_cache (line 56) | def invalidate_cache(_), do: :ok
    method lookup_cached_sound (line 58) | defp lookup_cached_sound(sound_name) do
    method resolve_and_cache_sound (line 68) | defp resolve_and_cache_sound(sound_name) do
    method resolve_upload_path (line 97) | defp resolve_upload_path(filename) do
    method cache_sound (line 101) | defp cache_sound(sound_name, meta) do

FILE: lib/soundboard/audio_player/voice_session.ex
  class Soundboard.AudioPlayer.VoiceSession (line 1) | defmodule Soundboard.AudioPlayer.VoiceSession
    method normalize_channel (line 10) | def normalize_channel(guild_id, channel_id) do
    method maintain_connection (line 26) | def maintain_connection(%State{} = state), do: state
    method maintenance_status (line 28) | defp maintenance_status(guild_id, channel_id) do
    method voice_ready (line 38) | defp voice_ready(guild_id) do
    method voice_playing (line 49) | defp voice_playing(guild_id) do
    method perform_maintenance (line 63) | defp perform_maintenance(%{playing?: true}, state), do: state
    method perform_maintenance (line 64) | defp perform_maintenance(%{joined?: true, ready?: true}, state), do: s...
    method perform_maintenance (line 66) | defp perform_maintenance(%{joined?: true} = status, state) do
    method perform_maintenance (line 81) | defp perform_maintenance(status, state) do
    method attempt_voice_join (line 89) | defp attempt_voice_join(state, guild_id, channel_id, action) do
    method safe_voice_ready (line 100) | defp safe_voice_ready(guild_id) do
    method safe_voice_playing (line 106) | defp safe_voice_playing(guild_id) do
    method safe_join_voice_channel (line 112) | defp safe_join_voice_channel(guild_id, channel_id) do

FILE: lib/soundboard/discord/bot_identity.ex
  class Soundboard.Discord.BotIdentity (line 1) | defmodule Soundboard.Discord.BotIdentity
    method fetch (line 7) | def fetch do
    method fetch_from_api (line 14) | defp fetch_from_api do
    method normalize_user (line 25) | defp normalize_user(%{id: id}), do: %{id: id}
    method normalize_user (line 26) | defp normalize_user(%{"id" => id}), do: %{id: id}
    method normalize_user (line 27) | defp normalize_user(_), do: %{}

FILE: lib/soundboard/discord/consumer.ex
  class Soundboard.Discord.Consumer (line 1) | defmodule Soundboard.Discord.Consumer
    method handle_event (line 8) | def handle_event({event_name, payload}) do

FILE: lib/soundboard/discord/guild_cache.ex
  class Soundboard.Discord.GuildCache (line 1) | defmodule Soundboard.Discord.GuildCache
    method all (line 6) | def all do
    method get (line 11) | def get(guild_id) do
    method get! (line 18) | def get!(guild_id) do
    method normalize_guild (line 25) | defp normalize_guild(guild) do
    method normalize_channels (line 38) | defp normalize_channels(channels, guild_id) do
    method normalize_voice_state (line 50) | defp normalize_voice_state(voice_state, guild_id) do
    method to_id (line 71) | defp to_id(value), do: to_string(value)

FILE: lib/soundboard/discord/handler.ex
  class Soundboard.Discord.Handler (line 1) | defmodule Soundboard.Discord.Handler
    method init (line 46) | def init do
    method start_link (line 50) | def start_link(_opts) do
    method dispatch_event (line 54) | def dispatch_event(event) do
    method init (line 67) | def init([]) do
    method handle_event (line 72) | def handle_event({:VOICE_STATE_UPDATE, %{channel_id: nil} = payload, _...
    method handle_event (line 85) | def handle_event({:VOICE_STATE_UPDATE, payload, _ws_state}) do
    method handle_event (line 113) | def handle_event({:READY, _payload, _ws_state}) do
    method handle_event (line 119) | def handle_event({:VOICE_READY, payload, _ws_state}) do
    method handle_event (line 129) | def handle_event({:VOICE_PLAYBACK_FINISHED, payload, _ws_state}) do
    method handle_event (line 134) | def handle_event({:VOICE_SERVER_UPDATE, _payload, _ws_state}), do: []
    method handle_event (line 136) | def handle_event({:VOICE_CHANNEL_STATUS_UPDATE, _payload, _ws_state}),...
    method handle_event (line 138) | def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
    method handle_event (line 143) | def handle_event(_event), do: []
    method handle_cast (line 146) | def handle_cast({:eda_event, event}, state) do
    method handle_info (line 155) | def handle_info({:event, {event_name, payload, ws_state}}, state) do
    method handle_info (line 163) | def handle_info({:recheck_alone, guild_id, channel_id}, state) do
    method handle_info (line 171) | def handle_info(_msg, state), do: {:noreply, state}
    method get_current_voice_channel (line 173) | def get_current_voice_channel do
    method apply_runtime_actions (line 181) | defp apply_runtime_actions(_actions), do: :ok
    method apply_runtime_action (line 183) | defp apply_runtime_action({:schedule_recheck_alone, guild_id, channel_...
    method apply_runtime_action (line 187) | defp apply_runtime_action(_action), do: :ok
  class State (line 10) | defmodule State
    method start_link (line 16) | def start_link(_) do
    method init (line 20) | def init(_) do
    method get_state (line 24) | def get_state(user_id) do
    method update_state (line 30) | def update_state(user_id, channel_id, session_id) do
    method handle_call (line 36) | def handle_call({:get_state, user_id}, _from, state) do
    method handle_cast (line 40) | def handle_cast({:update_state, user_id, channel_id, session_id}, stat...

FILE: lib/soundboard/discord/handler/auto_join_policy.ex
  class Soundboard.Discord.Handler.AutoJoinPolicy (line 1) | defmodule Soundboard.Discord.Handler.AutoJoinPolicy
    method mode (line 7) | def mode do
    method parse_mode (line 14) | defp parse_mode(nil), do: :play
    method parse_mode (line 16) | defp parse_mode(value) do

FILE: lib/soundboard/discord/handler/command_handler.ex
  class Soundboard.Discord.Handler.CommandHandler (line 1) | defmodule Soundboard.Discord.Handler.CommandHandler
    method handle_message (line 8) | def handle_message(%{content: "!join"} = msg) do
    method handle_message (line 25) | def handle_message(_msg), do: :ignore
    method joined_message (line 27) | defp joined_message do

FILE: lib/soundboard/discord/handler/idle_timeout_policy.ex
  class Soundboard.Discord.Handler.IdleTimeoutPolicy (line 1) | defmodule Soundboard.Discord.Handler.IdleTimeoutPolicy
    method timeout_ms (line 9) | def timeout_ms do
    method raw_seconds (line 16) | defp raw_seconds do

FILE: lib/soundboard/discord/handler/sound_effects.ex
  class Soundboard.Discord.Handler.SoundEffects (line 1) | defmodule Soundboard.Discord.Handler.SoundEffects
    method handle_join (line 9) | def handle_join(user_id, previous_state, guild_id, channel_id) do
    method handle_leave (line 28) | def handle_leave(user_id) do
    method play_join_sound (line 39) | defp play_join_sound(user_id, guild_id, channel_id) do
    method maybe_join_for_sound (line 56) | defp maybe_join_for_sound(guild_id, channel_id) do

FILE: lib/soundboard/discord/handler/voice_commands.ex
  class Soundboard.Discord.Handler.VoiceCommands (line 1) | defmodule Soundboard.Discord.Handler.VoiceCommands
    method join_voice_channel (line 9) | def join_voice_channel(guild_id, channel_id) do
    method leave_voice_channel (line 22) | def leave_voice_channel(guild_id) do
    method connected_to_discord? (line 35) | def connected_to_discord? do
    method execute (line 60) | defp execute(true, _skip_message, command_fun, success_fun, error_fun) do
    method execute (line 67) | defp execute(false, skip_message, _command_fun, _success_fun, _error_f...
    method run (line 71) | defp run(action, command) do
    method safely_run (line 87) | defp safely_run(command) do
    method rate_limited? (line 96) | defp rate_limited?(error_msg) do

FILE: lib/soundboard/discord/handler/voice_presence.ex
  class Soundboard.Discord.Handler.VoicePresence (line 1) | defmodule Soundboard.Discord.Handler.VoicePresence
    method current_voice_channel (line 9) | def current_voice_channel do
    method user_voice_channel (line 18) | def user_voice_channel(guild_id, user_id) do
    method bot_user? (line 25) | def bot_user?(user_id) do
    method bot_id (line 32) | def bot_id do
    method cached_guilds (line 41) | def cached_guilds do
    method find_user_voice_channel (line 47) | def find_user_voice_channel(discord_id) do
    method find_in_guild (line 58) | defp find_in_guild(guild, discord_id) do
    method users_in_channel (line 67) | def users_in_channel(guild_id, channel_id) do
    method count_users_in_channel (line 80) | defp count_users_in_channel(guild_id, channel_id) do
    method bot_id_value (line 98) | defp bot_id_value do
    method log_voice_state_snapshot (line 105) | defp log_voice_state_snapshot(channel_id, users_in_channel, bot_id, vo...
    method find_bot_voice_channel (line 115) | defp find_bot_voice_channel(bot_id) do
    method find_user_voice_channel (line 129) | defp find_user_voice_channel(guild, user_id) do
    method find_voice_channel_in_guilds (line 136) | defp find_voice_channel_in_guilds(guilds, bot_id) do
    method voice_channel_for_guild (line 140) | defp voice_channel_for_guild(guild, bot_id) do
    method fallback_voice_channel (line 152) | defp fallback_voice_channel do
    method valid_discord_id? (line 166) | defp valid_discord_id?(value), do: is_integer(value) or (is_binary(val...

FILE: lib/soundboard/discord/handler/voice_runtime.ex
  class Soundboard.Discord.Handler.VoiceRuntime (line 1) | defmodule Soundboard.Discord.Handler.VoiceRuntime
    method bootstrap (line 12) | def bootstrap do
    method join_voice_channel (line 18) | def join_voice_channel(guild_id, channel_id),
    method leave_voice_channel (line 21) | def leave_voice_channel(guild_id), do: VoiceCommands.leave_voice_chann...
    method handle_connect (line 24) | def handle_connect(payload) do
    method handle_disconnect (line 33) | def handle_disconnect(payload) do
    method recheck_alone (line 42) | def recheck_alone(guild_id, channel_id) do
    method get_current_voice_channel (line 51) | def get_current_voice_channel do
    method user_voice_channel (line 58) | def user_voice_channel(guild_id, user_id) do
    method bot_user? (line 65) | def bot_user?(user_id), do: VoicePresence.bot_user?(user_id)
    method start_guild_check_task (line 67) | defp start_guild_check_task do
    method check_guilds (line 75) | defp check_guilds do
    method process_guilds (line 88) | defp process_guilds(guilds) do
    method check_and_join_voice (line 93) | defp check_and_join_voice(guild) do
    method handle_recheck_alone (line 108) | defp handle_recheck_alone(guild_id, channel_id) do
    method maybe_act_if_bot_alone (line 119) | defp maybe_act_if_bot_alone(guild_id, _channel_id, 0) do
    method maybe_act_if_bot_alone (line 124) | defp maybe_act_if_bot_alone(_guild_id, _channel_id, _users), do: :ok
    method handle_bot_alone_check (line 126) | defp handle_bot_alone_check(_guild_id) do
    method check_and_maybe_act (line 133) | defp check_and_maybe_act(guild_id, channel_id) do
    method bot_alone_action (line 153) | defp bot_alone_action(guild_id) do
    method handle_auto_join_leave (line 160) | defp handle_auto_join_leave(payload) do
    method handle_user_rejoin_cancel (line 169) | defp handle_user_rejoin_cancel(payload) do
    method process_user_voice_update (line 186) | defp process_user_voice_update(payload) do
    method handle_bot_not_in_voice (line 200) | defp handle_bot_not_in_voice(payload) do
    method handle_bot_in_different_channel (line 216) | defp handle_bot_in_different_channel(guild_id, current_channel_id) do
    method maybe_join_channel_for_payload (line 234) | defp maybe_join_channel_for_payload(payload, users_in_channel) do
    method handle_current_channel_users (line 243) | defp handle_current_channel_users(guild_id, current_channel_id, 0) do
    method handle_current_channel_users (line 249) | defp handle_current_channel_users(guild_id, current_channel_id, _users...
    method schedule_recheck (line 253) | defp schedule_recheck(guild_id, channel_id),
    method current_voice_channel_status (line 256) | defp current_voice_channel_status do
    method current_bot_id (line 270) | defp current_bot_id do

FILE: lib/soundboard/discord/message.ex
  class Soundboard.Discord.Message (line 1) | defmodule Soundboard.Discord.Message
    method create (line 6) | def create(channel_id, payload) do
    method to_id (line 11) | defp to_id(value), do: to_string(value)

FILE: lib/soundboard/discord/role_checker.ex
  class Soundboard.Discord.RoleChecker (line 1) | defmodule Soundboard.Discord.RoleChecker
    method feature_enabled? (line 12) | def feature_enabled? do
    method authorized? (line 31) | def authorized?(user_id) do
    method check_member_roles (line 39) | defp check_member_roles(user_id) do
    method member_authorized? (line 58) | defp member_authorized?({:ok, _member}, user_id) do
    method member_authorized? (line 63) | defp member_authorized?({:error, reason}, user_id) do

FILE: lib/soundboard/discord/runtime_capability.ex
  class Soundboard.Discord.RuntimeCapability (line 1) | defmodule Soundboard.Discord.RuntimeCapability
    method discord_handler_enabled? (line 8) | def discord_handler_enabled? do
    method voice_runtime_available? (line 12) | def voice_runtime_available? do
    method voice_runtime_status (line 16) | def voice_runtime_status do
    method log_degraded_mode (line 32) | def log_degraded_mode do

FILE: lib/soundboard/discord/voice.ex
  class Soundboard.Discord.Voice (line 1) | defmodule Soundboard.Discord.Voice
    method join_channel (line 12) | def join_channel(guild_id, channel_id) do
    method leave_channel (line 16) | def leave_channel(guild_id) do
    method play (line 20) | def play(guild_id, input, type, opts \\ []) do
    method stop (line 32) | def stop(guild_id) do
    method ready? (line 36) | def ready?(guild_id) do
    method channel_id (line 40) | def channel_id(guild_id) do
    method playing? (line 44) | def playing?(guild_id) do
    method get_voice (line 49) | def get_voice(guild_id) do
    method play_with_supported_arity (line 58) | defp play_with_supported_arity(guild_id, input, type, opts) do
    method voice_module (line 74) | defp voice_module do
    method to_id (line 78) | defp to_id(nil), do: nil
    method to_id (line 80) | defp to_id(value), do: to_string(value)

FILE: lib/soundboard/favorites.ex
  class Soundboard.Favorites (line 1) | defmodule Soundboard.Favorites
    method list_favorites (line 15) | def list_favorites(user_id) do
    method list_favorite_sounds_with_tags (line 23) | def list_favorite_sounds_with_tags(user_id) do
    method toggle_favorite (line 36) | def toggle_favorite(user_id, sound_id) do
    method error_message (line 44) | def error_message(%Ecto.Changeset{} = changeset) do
    method add_favorite (line 52) | defp add_favorite(user_id, sound_id) do
    method favorite? (line 78) | def favorite?(user_id, sound_id) do
    method max_favorites (line 83) | def max_favorites, do: @max_favorites

FILE: lib/soundboard/favorites/favorite.ex
  class Soundboard.Favorites.Favorite (line 1) | defmodule Soundboard.Favorites.Favorite
    method changeset (line 18) | def changeset(favorite, attrs) do

FILE: lib/soundboard/public_url.ex
  class Soundboard.PublicURL (line 1) | defmodule Soundboard.PublicURL
    method current (line 10) | def current, do: SoundboardWeb.Endpoint.url()
    method from_uri_or_current (line 12) | def from_uri_or_current(nil), do: current()
    method from_uri_or_current (line 14) | def from_uri_or_current(uri) do
    method port_suffix (line 24) | defp port_suffix("http", 80), do: ""
    method port_suffix (line 25) | defp port_suffix("https", 443), do: ""
    method port_suffix (line 26) | defp port_suffix(_scheme, nil), do: ""
    method port_suffix (line 27) | defp port_suffix(_scheme, port), do: ":#{port}"

FILE: lib/soundboard/pubsub_topics.ex
  class Soundboard.PubSubTopics (line 1) | defmodule Soundboard.PubSubTopics
    method files_topic (line 10) | def files_topic, do: @files_topic
    method playback_topic (line 11) | def playback_topic, do: @playback_topic
    method stats_topic (line 12) | def stats_topic, do: @stats_topic
    method subscribe_files (line 14) | def subscribe_files, do: PubSub.subscribe(Soundboard.PubSub, @files_to...
    method subscribe_playback (line 15) | def subscribe_playback, do: PubSub.subscribe(Soundboard.PubSub, @playb...
    method subscribe_stats (line 16) | def subscribe_stats, do: PubSub.subscribe(Soundboard.PubSub, @stats_to...
    method broadcast_files_updated (line 18) | def broadcast_files_updated do
    method broadcast_stats_updated (line 22) | def broadcast_stats_updated do
    method broadcast_sound_played (line 26) | def broadcast_sound_played(sound_name, username) do
    method broadcast_error (line 34) | def broadcast_error(message) do

FILE: lib/soundboard/release.ex
  class Soundboard.Release (line 1) | defmodule Soundboard.Release
    method migrate (line 6) | def migrate do
    method rollback (line 17) | def rollback(repo, version) do
    method repos (line 26) | defp repos do
    method load_app (line 30) | defp load_app do

FILE: lib/soundboard/repo.ex
  class Soundboard.Repo (line 1) | defmodule Soundboard.Repo

FILE: lib/soundboard/sound.ex
  class Soundboard.Sound (line 1) | defmodule Soundboard.Sound
    method changeset (line 33) | def changeset(sound, attrs) do
    method with_tags (line 50) | def with_tags(query \\ __MODULE__) do
    method by_tag (line 55) | def by_tag(query \\ __MODULE__, tag_name) do
    method validate_source_type (line 61) | defp validate_source_type(changeset) do
    method put_tags (line 73) | defp put_tags(changeset, _), do: changeset
    method validate_volume (line 75) | defp validate_volume(changeset) do

FILE: lib/soundboard/sound_tag.ex
  class Soundboard.SoundTag (line 1) | defmodule Soundboard.SoundTag
    method changeset (line 15) | def changeset(sound_tag, attrs) do

FILE: lib/soundboard/sounds.ex
  class Soundboard.Sounds (line 1) | defmodule Soundboard.Sounds
    method list_files (line 20) | def list_files do
    method list_detailed (line 28) | def list_detailed do
    method ids_by_filename (line 43) | def ids_by_filename([]), do: %{}
    method filename_taken_excluding? (line 58) | def filename_taken_excluding?(filename, sound_id) do
    method fetch_filename_extension (line 72) | def fetch_filename_extension(sound_id) do
    method get_recent_uploads (line 80) | def get_recent_uploads(opts \\ []) do
    method get_user_join_sound (line 94) | def get_user_join_sound(user_id) do
    method get_user_leave_sound (line 105) | def get_user_leave_sound(user_id) do
    method get_user_join_sound_by_discord_id (line 116) | def get_user_join_sound_by_discord_id(discord_id) do
    method get_user_leave_sound_by_discord_id (line 130) | def get_user_leave_sound_by_discord_id(discord_id) do
    method get_user_sound_preferences_by_discord_id (line 144) | def get_user_sound_preferences_by_discord_id(discord_id) do
    method get_sound! (line 159) | def get_sound!(id) do
    method update_sound (line 166) | def update_sound(sound, attrs) do
    method update_sound (line 173) | def update_sound(sound, user_id, params), do: Management.update_sound(...
    method delete_sound (line 176) | def delete_sound(sound, user_id), do: Management.delete_sound(sound, u...
    method new_create_request (line 179) | def new_create_request(user, attrs), do: CreateRequest.new(user, attrs)
    method put_request_upload (line 182) | def put_request_upload(request, upload), do: CreateRequest.put_upload(...
    method validate_create (line 185) | def validate_create(request), do: Uploads.validate(request)
    method create_sound (line 188) | def create_sound(request), do: Uploads.create(request)
    method create_error_message (line 191) | def create_error_message(error), do: Uploads.error_message(error)

FILE: lib/soundboard/sounds/management.ex
  class Soundboard.Sounds.Management (line 1) | defmodule Soundboard.Sounds.Management
    method update_sound (line 14) | def update_sound(%Sound{} = sound, user_id, params) do
    method delete_sound (line 53) | def delete_sound(%Sound{} = sound, user_id) do
    method maybe_remove_local_file (line 67) | defp maybe_remove_local_file(%{source_type: "local", filename: filenam...
    method maybe_remove_local_file (line 72) | defp maybe_remove_local_file(_), do: :ok
    method maybe_rename_local_file (line 74) | defp maybe_rename_local_file(%{source_type: "local"} = sound, old_path...
    method maybe_rename_local_file (line 98) | defp maybe_rename_local_file(_, _, _), do: :ok
    method update_user_settings (line 100) | defp update_user_settings(sound, user_id, updated_sound, params) do

FILE: lib/soundboard/sounds/tags.ex
  class Soundboard.Sounds.Tags (line 1) | defmodule Soundboard.Sounds.Tags
    method search (line 10) | def search(query) do
    method all_for_sounds (line 15) | def all_for_sounds(sounds) do
    method count_sounds_with_tag (line 22) | def count_sounds_with_tag(sounds, tag) do
    method tag_selected? (line 28) | def tag_selected?(tag, selected_tags) do
    method update_sound_tags (line 32) | def update_sound_tags(sound, tags) do
    method resolve_many (line 55) | def resolve_many(_), do: {:ok, []}
    method resolve (line 57) | def resolve(%Tag{} = tag), do: {:ok, tag}
    method resolve (line 72) | def resolve(_), do: {:ok, nil}
    method list_for_sound (line 83) | def list_for_sound(filename) do
    method insert_or_get (line 90) | defp insert_or_get(name) do
    method fetch_after_insert_conflict (line 97) | defp fetch_after_insert_conflict(name) do

FILE: lib/soundboard/sounds/uploads.ex
  class Soundboard.Sounds.Uploads (line 1) | defmodule Soundboard.Sounds.Uploads
    method validate (line 15) | def validate(%CreateRequest{} = request) do
    method create (line 23) | def create(%CreateRequest{} = request) do
    method error_message (line 34) | def error_message(%Ecto.Changeset{} = changeset) do
    method error_message (line 43) | def error_message(_), do: "An unexpected error occurred"
    method normalize_create_error (line 45) | defp normalize_create_error(%Ecto.Changeset{} = changeset), do: changeset
    method normalize_create_error (line 47) | defp normalize_create_error(_reason), do: add_base_error("An unexpecte...
    method add_base_error (line 49) | defp add_base_error(message) do

FILE: lib/soundboard/sounds/uploads/create_request.ex
  class Soundboard.Sounds.Uploads.CreateRequest (line 1) | defmodule Soundboard.Sounds.Uploads.CreateRequest
    method put_upload (line 59) | def put_upload(%__MODULE__{} = request, upload) do
    method normalize_upload (line 63) | defp normalize_upload(nil), do: nil
    method normalize_upload (line 72) | defp normalize_upload(_), do: nil
    method get_param (line 74) | defp get_param(map, key, default \\ nil) do

FILE: lib/soundboard/sounds/uploads/creator.ex
  class Soundboard.Sounds.Uploads.Creator (line 1) | defmodule Soundboard.Sounds.Uploads.Creator
    method create (line 9) | def create(params, source) do
    method insert_sound (line 31) | defp insert_sound(params, source, tags) do
    method insert_user_setting (line 46) | defp insert_user_setting(sound, params) do
    method broadcast_updates (line 66) | defp broadcast_updates do

FILE: lib/soundboard/sounds/uploads/normalizer.ex
  class Soundboard.Sounds.Uploads.Normalizer (line 1) | defmodule Soundboard.Sounds.Uploads.Normalizer
    method normalize (line 10) | def normalize(%CreateRequest{} = request) do
    method build_normalized_params (line 34) | defp build_normalized_params(%{
    method build_normalized_params (line 65) | defp build_normalized_params(_params) do
    method normalize_default_volume (line 69) | defp normalize_default_volume(value), do: Volume.normalize_percent(val...
    method normalize_tags (line 71) | defp normalize_tags(nil), do: []
    method normalize_tags (line 81) | defp normalize_tags(_), do: []
    method normalize_source_type (line 91) | defp normalize_source_type(_source_type, upload, url), do: infer_sourc...
    method infer_source_type (line 93) | defp infer_source_type(upload, url) do
    method normalize_name (line 102) | defp normalize_name(_), do: nil
    method normalize_url (line 105) | defp normalize_url(_), do: nil
    method to_boolean (line 108) | defp to_boolean(_), do: false
    method blank? (line 110) | defp blank?(value), do: value in [nil, ""]

FILE: lib/soundboard/sounds/uploads/source.ex
  class Soundboard.Sounds.Uploads.Source (line 1) | defmodule Soundboard.Sounds.Uploads.Source
    method prepare (line 14) | def prepare(%{source_type: "url"} = params, _mode) do
    method prepare (line 28) | def prepare(%{source_type: "local"} = params, :validate) do
    method prepare (line 43) | def prepare(%{source_type: "local"} = params, :create) do
    method prepare (line 59) | def prepare(_params, _mode) do
    method cleanup_local_file (line 75) | def cleanup_local_file(_path), do: :ok
    method validate_url (line 85) | defp validate_url(_url), do: {:error, add_error(change(%Sound{}), :url...
    method validate_local_upload (line 87) | defp validate_local_upload(nil, _mode),
    method validate_local_upload (line 90) | defp validate_local_upload(%{filename: filename} = upload, :validate) do
    method validate_local_upload (line 106) | defp validate_local_upload(_, _mode),
    method validate_local_extension (line 109) | defp validate_local_extension(filename) do
    method copy_local_file (line 124) | defp copy_local_file(src_path, filename) do
    method ensure_uploads_dir (line 137) | defp ensure_uploads_dir(uploads_dir) do
    method validate_destination_filename (line 144) | defp validate_destination_filename(filename) do
    method filename_taken? (line 154) | defp filename_taken?(filename) do
    method url_file_extension (line 172) | defp url_file_extension(_), do: ""
    method blank? (line 174) | defp blank?(value), do: value in [nil, ""]

FILE: lib/soundboard/stats.ex
  class Soundboard.Stats (line 1) | defmodule Soundboard.Stats
    method track_play (line 15) | def track_play(sound_name, user_id) do
    method get_week_range (line 27) | defp get_week_range do
    method get_top_users (line 40) | def get_top_users(start_date, end_date, opts \\ []) do
    method get_top_sounds (line 55) | def get_top_sounds(start_date, end_date, opts \\ []) do
    method get_recent_plays (line 69) | def get_recent_plays(opts \\ []) do
    method reset_weekly_stats (line 83) | def reset_weekly_stats do
    method broadcast_stats_update (line 93) | def broadcast_stats_update do
    method insert_play (line 97) | defp insert_play(attrs) do

FILE: lib/soundboard/stats/play.ex
  class Soundboard.Stats.Play (line 1) | defmodule Soundboard.Stats.Play
    method changeset (line 20) | def changeset(play, attrs) do

FILE: lib/soundboard/tag.ex
  class Soundboard.Tag (line 1) | defmodule Soundboard.Tag
    method changeset (line 19) | def changeset(tag, attrs) do
    method search (line 26) | def search(query \\ __MODULE__, search_term) do

FILE: lib/soundboard/uploads_path.ex
  class Soundboard.UploadsPath (line 1) | defmodule Soundboard.UploadsPath
    method dir (line 10) | def dir do
    method safe_joined_path (line 28) | def safe_joined_path(path) do
    method within_uploads_dir? (line 47) | defp within_uploads_dir?(candidate, base_dir) do

FILE: lib/soundboard/user_sound_setting.ex
  class Soundboard.UserSoundSetting (line 1) | defmodule Soundboard.UserSoundSetting
    method changeset (line 20) | def changeset(settings, attrs) do
    method clear_conflicting_settings (line 26) | def clear_conflicting_settings(user_id, sound_id, is_join_sound, is_le...
    method maybe_clear_join_sound (line 32) | defp maybe_clear_join_sound(user_id, sound_id, true) do
    method maybe_clear_join_sound (line 44) | defp maybe_clear_join_sound(_user_id, _sound_id, _is_join_sound), do: :ok
    method maybe_clear_leave_sound (line 46) | defp maybe_clear_leave_sound(user_id, sound_id, true) do
    method maybe_clear_leave_sound (line 58) | defp maybe_clear_leave_sound(_user_id, _sound_id, _is_leave_sound), do...

FILE: lib/soundboard/volume.ex
  class Soundboard.Volume (line 1) | defmodule Soundboard.Volume
    method clamp_percent (line 9) | def clamp_percent(value) do
    method normalize_percent (line 17) | def normalize_percent(value, default_percent) do
    method percent_to_decimal (line 24) | def percent_to_decimal(percent), do: percent_to_decimal(percent, 100)
    method percent_to_decimal (line 27) | def percent_to_decimal(value, default_percent) do
    method decimal_to_percent (line 34) | def decimal_to_percent(nil), do: 100
    method do_decimal_to_percent (line 49) | defp do_decimal_to_percent(value) do
    method do_normalize (line 57) | defp do_normalize(default, nil), do: default
    method do_normalize (line 71) | defp do_normalize(default, _), do: default

FILE: lib/soundboard_web.ex
  class SoundboardWeb (line 1) | defmodule SoundboardWeb
    method static_paths (line 20) | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
    method router (line 22) | def router do
    method channel (line 33) | def channel do
    method controller (line 39) | def controller do
    method live_view (line 53) | def live_view do
    method live_component (line 62) | def live_component do
    method html (line 70) | def html do
    method html_helpers (line 83) | defp html_helpers do
    method verified_routes (line 102) | def verified_routes do

FILE: lib/soundboard_web/components/core_components.ex
  class SoundboardWeb.CoreComponents (line 1) | defmodule SoundboardWeb.CoreComponents
    method modal (line 45) | def modal(assigns) do
    method flash (line 109) | def flash(assigns) do
    method flash_group (line 148) | def flash_group(assigns) do
    method simple_form (line 203) | def simple_form(assigns) do
    method button (line 230) | def button(assigns) do
    method input (line 295) | def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    method input (line 306) | def input(%{type: "checkbox"} = assigns) do
    method input (line 332) | def input(%{type: "select"} = assigns) do
    method input (line 351) | def input(%{type: "textarea"} = assigns) do
    method input (line 371) | def input(assigns) do
    method label (line 398) | def label(assigns) do
    method error (line 411) | def error(assigns) do
    method header (line 429) | def header(assigns) do
    method table (line 470) | def table(assigns) do
    method list (line 537) | def list(assigns) do
    method back (line 560) | def back(assigns) do
    method icon (line 595) | def icon(%{name: "hero-" <> _} = assigns) do
    method show (line 603) | def show(js \\ %JS{}, selector) do
    method hide (line 614) | def hide(js \\ %JS{}, selector) do
    method hide_modal (line 638) | def hide_modal(js \\ %JS{}, id) do
    method translate_error (line 653) | def translate_error({msg, opts}) do

FILE: lib/soundboard_web/components/flash_component.ex
  class SoundboardWeb.Components.FlashComponent (line 1) | defmodule SoundboardWeb.Components.FlashComponent
    method flash (line 7) | def flash(assigns) do

FILE: lib/soundboard_web/components/layouts.ex
  class SoundboardWeb.Layouts (line 1) | defmodule SoundboardWeb.Layouts

FILE: lib/soundboard_web/components/layouts/navbar.ex
  class SoundboardWeb.Components.Layouts.Navbar (line 1) | defmodule SoundboardWeb.Components.Layouts.Navbar
    method mount (line 9) | def mount(socket) do
    method handle_event (line 14) | def handle_event("toggle-mobile-menu", _, socket) do
    method render (line 19) | def render(assigns) do
    method nav_link (line 175) | defp nav_link(assigns) do
    method mobile_nav_link (line 193) | defp mobile_nav_link(assigns) do
    method visible_users (line 211) | defp visible_users(presences) do
    method current_page? (line 219) | defp current_page?(current_path, path), do: current_path == path

FILE: lib/soundboard_web/components/soundboard/delete_modal.ex
  class SoundboardWeb.Components.Soundboard.DeleteModal (line 1) | defmodule SoundboardWeb.Components.Soundboard.DeleteModal
    method delete_modal (line 7) | def delete_modal(assigns) do

FILE: lib/soundboard_web/components/soundboard/edit_modal.ex
  class SoundboardWeb.Components.Soundboard.EditModal (line 1) | defmodule SoundboardWeb.Components.Soundboard.EditModal
    method edit_modal (line 16) | def edit_modal(assigns) do

FILE: lib/soundboard_web/components/soundboard/helpers.ex
  class SoundboardWeb.Components.Soundboard.Helpers (line 1) | defmodule SoundboardWeb.Components.Soundboard.Helpers

FILE: lib/soundboard_web/components/soundboard/tag_components.ex
  class SoundboardWeb.Components.Soundboard.TagComponents (line 1) | defmodule SoundboardWeb.Components.Soundboard.TagComponents
    method tag_badge_list (line 13) | def tag_badge_list(assigns) do
    method tag_suggestions_dropdown (line 51) | def tag_suggestions_dropdown(assigns) do
    method tag_filter_button (line 80) | def tag_filter_button(assigns) do
    method tag_input_field (line 112) | def tag_input_field(assigns) do
    method tag_value (line 144) | defp tag_value(tag, _tag_key), do: tag

FILE: lib/soundboard_web/components/soundboard/upload_modal.ex
  class SoundboardWeb.Components.Soundboard.UploadModal (line 1) | defmodule SoundboardWeb.Components.Soundboard.UploadModal
    method upload_modal (line 8) | def upload_modal(assigns) do
    method source_input_ready? (line 258) | defp source_input_ready?("local", entries, _url), do: entries != []
    method source_input_ready? (line 259) | defp source_input_ready?("url", _entries, url), do: String.trim(url ||...
    method source_input_ready? (line 260) | defp source_input_ready?(_, _entries, _url), do: false
    method form_ready? (line 262) | defp form_ready?(source_type, entries, url, upload_error) do
    method local_upload_pending? (line 266) | defp local_upload_pending?(source_type, entries), do: source_type == "...
    method url_upload_pending? (line 268) | defp url_upload_pending?(source_type, url),

FILE: lib/soundboard_web/components/soundboard/volume_control.ex
  class SoundboardWeb.Components.Soundboard.VolumeControl (line 1) | defmodule SoundboardWeb.Components.Soundboard.VolumeControl
    method volume_control (line 18) | def volume_control(assigns) do

FILE: lib/soundboard_web/controllers/api/sound_controller.ex
  class SoundboardWeb.API.SoundController (line 1) | defmodule SoundboardWeb.API.SoundController
    method index (line 6) | def index(conn, _params) do
    method create (line 16) | def create(conn, params) do
    method play (line 35) | def play(conn, %{"id" => id}) do
    method stop (line 66) | def stop(conn, _params) do
    method create_sound (line 79) | defp create_sound(user, params) do
    method require_upload_user (line 85) | defp require_upload_user(conn) do
    method require_play_user (line 92) | defp require_play_user(conn), do: require_upload_user(conn)
    method format_sound (line 94) | defp format_sound(sound, current_user) do
    method find_user_setting (line 112) | defp find_user_setting(_sound, nil), do: nil
    method find_user_setting (line 114) | defp find_user_setting(sound, user) do
    method changeset_errors (line 127) | defp changeset_errors(changeset) do

FILE: lib/soundboard_web/controllers/auth_controller.ex
  class SoundboardWeb.AuthController (line 1) | defmodule SoundboardWeb.AuthController
    method request (line 10) | def request(conn, %{"provider" => "discord"} = _params) do
    method request (line 16) | def request(conn, _params) do
    method callback (line 22) | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
    method callback (line 49) | def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
    method find_or_create_user (line 55) | defp find_or_create_user(%{discord_id: discord_id} = params) do
    method logout (line 67) | def logout(conn, _params) do
    method debug_session (line 73) | def debug_session(conn, _params) do

FILE: lib/soundboard_web/controllers/error_html.ex
  class SoundboardWeb.ErrorHTML (line 1) | defmodule SoundboardWeb.ErrorHTML
    method render (line 7) | def render(template, _assigns) do

FILE: lib/soundboard_web/controllers/error_json.ex
  class SoundboardWeb.ErrorJSON (line 1) | defmodule SoundboardWeb.ErrorJSON
    method render (line 6) | def render(template, _assigns) do

FILE: lib/soundboard_web/controllers/upload_controller.ex
  class SoundboardWeb.UploadController (line 1) | defmodule SoundboardWeb.UploadController
    method show (line 6) | def show(conn, %{"path" => path}) do

FILE: lib/soundboard_web/endpoint.ex
  class SoundboardWeb.Endpoint (line 1) | defmodule SoundboardWeb.Endpoint

FILE: lib/soundboard_web/gettext.ex
  class SoundboardWeb.Gettext (line 1) | defmodule SoundboardWeb.Gettext

FILE: lib/soundboard_web/live/favorites_live.ex
  class SoundboardWeb.FavoritesLive (line 1) | defmodule SoundboardWeb.FavoritesLive
    method mount (line 10) | def mount(_params, session, socket) do
    method handle_event (line 27) | def handle_event("play", %{"name" => filename}, socket) do
    method handle_event (line 32) | def handle_event("toggle_favorite", %{"sound-id" => sound_id}, socket) do
    method handle_info (line 52) | def handle_info({:sound_played, %{filename: _, played_by: _} = event},...
    method handle_info (line 71) | def handle_info({:error, message}, socket) do
    method handle_info (line 79) | def handle_info({:files_updated}, socket) do
    method handle_info (line 84) | def handle_info(:clear_flash, socket) do
    method handle_info (line 89) | def handle_info({:stats_updated}, socket) do
    method assign_favorites_state (line 93) | defp assign_favorites_state(socket, nil) do
    method assign_favorites_state (line 97) | defp assign_favorites_state(socket, user) do

FILE: lib/soundboard_web/live/settings_live.ex
  class SoundboardWeb.SettingsLive (line 1) | defmodule SoundboardWeb.SettingsLive
    method mount (line 8) | def mount(_params, session, socket) do
    method handle_params (line 22) | def handle_params(_params, uri, socket) do
    method handle_event (line 27) | def handle_event(
    method handle_event (line 45) | def handle_event("revoke_token", %{"id" => id}, %{assigns: %{current_u...
    method load_tokens (line 54) | defp load_tokens(%{assigns: %{current_user: nil}} = socket), do: socket
    method load_tokens (line 56) | defp load_tokens(%{assigns: %{current_user: user}} = socket) do
    method render (line 72) | def render(assigns) do
    method format_dt (line 290) | defp format_dt(nil), do: nil
    method format_dt (line 291) | defp format_dt(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%...

FILE: lib/soundboard_web/live/soundboard_live.ex
  class SoundboardWeb.SoundboardLive (line 1) | defmodule SoundboardWeb.SoundboardLive
    method mount (line 18) | def mount(_params, session, socket) do
    method assign_initial_state (line 44) | defp assign_initial_state(socket) do
    method handle_event (line 68) | def handle_event("validate", _params, socket), do: {:noreply, socket}
    method handle_event (line 71) | def handle_event("change_source_type", %{"source_type" => source_type}...
    method handle_event (line 76) | def handle_event("validate_sound", params, socket) do
    method handle_event (line 81) | def handle_event("toggle_tag_list", _params, socket) do
    method handle_event (line 86) | def handle_event("play", %{"name" => filename}, socket) do
    method handle_event (line 91) | def handle_event("search", %{"query" => query}, socket) do
    method handle_event (line 96) | def handle_event("toggle_tag_filter", %{"tag" => tag_name}, socket) do
    method handle_event (line 113) | def handle_event("clear_tag_filters", _, socket) do
    method handle_event (line 118) | def handle_event("edit", %{"id" => id}, socket) do
    method handle_event (line 123) | def handle_event("save_upload", params, socket) do
    method handle_event (line 128) | def handle_event("validate_upload", params, socket) do
    method handle_event (line 133) | def handle_event("show_upload_modal", _params, socket) do
    method handle_event (line 138) | def handle_event("hide_upload_modal", _params, socket) do
    method handle_event (line 143) | def handle_event("add_upload_tag", %{"key" => key, "value" => value}, ...
    method handle_event (line 148) | def handle_event("remove_upload_tag", %{"tag" => tag_name}, socket) do
    method handle_event (line 153) | def handle_event("select_upload_tag_suggestion", %{"tag" => tag_name},...
    method handle_event (line 158) | def handle_event("upload_tag_input", %{"key" => _key, "value" => value...
    method handle_event (line 163) | def handle_event("add_tag", %{"key" => key, "value" => value}, socket) do
    method handle_event (line 168) | def handle_event("remove_tag", %{"tag" => tag_name}, socket) do
    method handle_event (line 173) | def handle_event("select_tag_suggestion", %{"tag" => tag_name}, socket...
    method handle_event (line 178) | def handle_event("tag_input", %{"key" => _key, "value" => value}, sock...
    method handle_event (line 183) | def handle_event("select_tag", %{"tag" => tag_name}, socket) do
    method handle_event (line 188) | def handle_event("save_sound", params, socket) do
    method handle_event (line 193) | def handle_event("close_upload_modal", _params, socket) do
    method handle_event (line 198) | def handle_event("close_modal", _params, socket) do
    method handle_event (line 206) | def handle_event("close_modal_key", %{"key" => "Escape"}, socket) do
    method handle_event (line 218) | def handle_event("select_upload_tag", %{"tag" => tag_name}, socket) do
    method handle_event (line 223) | def handle_event("toggle_favorite", %{"sound-id" => sound_id}, socket) do
    method handle_event (line 243) | def handle_event("show_delete_confirm", _params, socket) do
    method handle_event (line 248) | def handle_event("hide_delete_confirm", _params, socket) do
    method handle_event (line 253) | def handle_event("delete_sound", _params, socket) do
    method handle_event (line 258) | def handle_event("toggle_join_sound", _params, socket) do
    method handle_event (line 263) | def handle_event("toggle_leave_sound", _params, socket) do
    method handle_event (line 268) | def handle_event("update_volume", %{"volume" => volume, "target" => "e...
    method handle_event (line 273) | def handle_event("update_volume", %{"volume" => volume, "target" => "u...
    method handle_event (line 278) | def handle_event("update_volume", _params, socket), do: {:noreply, soc...
    method handle_event (line 281) | def handle_event("play_random", _params, socket) do
    method handle_event (line 299) | def handle_event("stop_sound", _params, socket) do
    method handle_info (line 312) | def handle_info({:error, message}, socket) do
    method handle_info (line 317) | def handle_info({:sound_played, %{filename: _, played_by: _} = event},...
    method handle_info (line 322) | def handle_info(:clear_flash, socket) do
    method handle_info (line 327) | def handle_info({:files_updated}, socket) do
    method handle_info (line 332) | def handle_info(:load_sound_files, socket) do
    method assign_favorites (line 339) | defp assign_favorites(socket, nil), do: assign(socket, :favorites, [])
    method assign_favorites (line 341) | defp assign_favorites(socket, user) do
    method load_sound_files (line 346) | defp load_sound_files(socket) do
    method get_random_sound (line 350) | defp get_random_sound([]), do: nil
    method get_random_sound (line 352) | defp get_random_sound(sounds) do
    method handle_progress (line 356) | defp handle_progress(:audio, _entry, socket) do

FILE: lib/soundboard_web/live/soundboard_live/edit_flow.ex
  class SoundboardWeb.Live.SoundboardLive.EditFlow (line 1) | defmodule SoundboardWeb.Live.SoundboardLive.EditFlow
    method assign_defaults (line 33) | def assign_defaults(socket), do: put_state(socket, default_state())
    method validate_sound (line 35) | def validate_sound(socket, %{"_target" => ["filename"]} = params) do
    method validate_sound (line 54) | def validate_sound(socket, _params), do: {:noreply, socket}
    method open_modal (line 56) | def open_modal(socket, id) do
    method close_modal (line 66) | def close_modal(socket), do: put_state(socket, default_state())
    method add_tag (line 68) | def add_tag(socket, key, value) do
    method remove_tag (line 74) | def remove_tag(socket, tag_name) do
    method select_tag_suggestion (line 87) | def select_tag_suggestion(socket, tag_name), do: select_tag(socket, ta...
    method update_tag_input (line 89) | def update_tag_input(socket, value), do: TagForm.update_input(socket, ...
    method select_tag (line 91) | def select_tag(socket, tag_name) do
    method save_sound (line 97) | def save_sound(socket, params) do
    method show_delete_confirm (line 120) | def show_delete_confirm(socket) do
    method hide_delete_confirm (line 124) | def hide_delete_confirm(socket) do
    method delete_sound (line 128) | def delete_sound(socket) do
    method update_volume (line 153) | def update_volume(socket, volume) do
    method error_message (line 170) | defp error_message(%Ecto.Changeset{} = changeset) do
    method error_message (line 176) | defp error_message(_), do: "Failed to update sound"
    method append_sound_tag (line 178) | defp append_sound_tag(socket, tag, current_tags) do
    method current_tags (line 192) | defp current_tags(_state), do: []
    method default_state (line 194) | defp default_state, do: %State{}
    method state (line 196) | defp state(socket) do
    method put_state (line 215) | defp put_state(socket, %State{} = state) do
  class State (line 11) | defmodule State

FILE: lib/soundboard_web/live/soundboard_live/upload_flow.ex
  class SoundboardWeb.Live.SoundboardLive.UploadFlow (line 1) | defmodule SoundboardWeb.Live.SoundboardLive.UploadFlow
    method assign_defaults (line 47) | def assign_defaults(socket), do: put_state(socket, default_state())
    method change_source_type (line 49) | def change_source_type(socket, source_type) do
    method save (line 53) | def save(socket, params, consume_uploaded_entries_fn) do
    method validate (line 86) | def validate(socket, params) do
    method show_modal (line 101) | def show_modal(socket) do
    method hide_modal (line 108) | def hide_modal(socket), do: {:noreply, close_modal(socket)}
    method close_modal (line 110) | def close_modal(socket) do
    method add_tag (line 116) | def add_tag(socket, key, value) do
    method remove_tag (line 122) | def remove_tag(socket, tag_name) do
    method select_tag_suggestion (line 129) | def select_tag_suggestion(socket, tag_name), do: select_tag(socket, ta...
    method update_tag_input (line 131) | def update_tag_input(socket, value), do: TagForm.update_input(socket, ...
    method select_tag (line 133) | def select_tag(socket, tag_name) do
    method toggle_join_sound (line 139) | def toggle_join_sound(socket) do
    method toggle_leave_sound (line 143) | def toggle_leave_sound(socket) do
    method update_volume (line 147) | def update_volume(socket, volume) do
    method append_upload_tag (line 154) | defp append_upload_tag(socket, tag, current_tags) do
    method reset_state (line 158) | defp reset_state(socket), do: put_state(socket, default_state())
    method normalize_params (line 160) | defp normalize_params(upload, params) do
    method assign_params (line 167) | defp assign_params(socket, upload, params, error) do
    method validate_existing_entries (line 179) | defp validate_existing_entries(socket, %State{audio_entries: []}), do:...
    method validate_existing_entries (line 181) | defp validate_existing_entries(socket, %State{} = upload) do
    method validate_audio (line 194) | defp validate_audio(entry) do
    method validate_request (line 201) | defp validate_request(upload, %{"source_type" => "url", "name" => name...
    method validate_request (line 212) | defp validate_request(upload, params) do
    method handle_save_results (line 224) | defp handle_save_results(socket, [{:ok, _sound}]) do
    method handle_save_results (line 232) | defp handle_save_results(socket, [{:error, changeset}]) do
    method handle_save_results (line 236) | defp handle_save_results(socket, []) do
    method handle_save_results (line 249) | defp handle_save_results(socket, _results) do
    method build_request (line 253) | defp build_request(%State{} = upload, params) do
    method default_upload_name (line 266) | defp default_upload_name(upload, params) do
    method inferred_upload_name (line 285) | defp inferred_upload_name(_), do: ""
    method inferred_url_name (line 299) | defp inferred_url_name(_), do: ""
    method default_state (line 301) | defp default_state, do: %State{}
    method state (line 303) | defp state(socket) do
    method put_state (line 329) | defp put_state(socket, %State{} = state) do
    method audio_entries (line 345) | defp audio_entries(socket) do
    method current_upload (line 355) | defp current_upload(socket) do
    method blank? (line 367) | defp blank?(value), do: value in [nil, ""]
    method present? (line 368) | defp present?(value), do: not blank?(value)
  class State (line 11) | defmodule State

FILE: lib/soundboard_web/live/stats_live.ex
  class SoundboardWeb.StatsLive (line 1) | defmodule SoundboardWeb.StatsLive
    method mount (line 15) | def mount(_params, session, socket) do
    method handle_info (line 38) | def handle_info({:sound_played, %{filename: filename, played_by: usern...
    method handle_info (line 49) | def handle_info({:stats_updated}, socket) do
    method handle_info (line 54) | def handle_info({:error, message}, socket) do
    method handle_info (line 62) | def handle_info(:clear_flash, socket) do
    method assign_stats (line 66) | defp assign_stats(socket) do
    method get_favorites (line 88) | defp get_favorites(nil), do: []
    method get_favorites (line 89) | defp get_favorites(user), do: Favorites.list_favorites(user.id)
    method format_timestamp (line 91) | defp format_timestamp(timestamp) do
    method get_week_range (line 97) | defp get_week_range(date \\ Date.utc_today()) do
    method format_date_range (line 104) | defp format_date_range({start_date, end_date}) do
    method date_input_value (line 108) | defp date_input_value({start_date, _end_date}) do
    method parse_week_input (line 112) | defp parse_week_input(nil), do: :error
    method parse_week_input (line 113) | defp parse_week_input(""), do: :error
    method parse_week_input (line 115) | defp parse_week_input(week_value) do
    method render (line 123) | def render(assigns) do
    method get_user_color_from_presence (line 341) | defp get_user_color_from_presence(username, presences) do
    method handle_favorite_toggle (line 353) | defp handle_favorite_toggle(socket, user, sound_name) do
    method update_favorite (line 360) | defp update_favorite(socket, user, sound_id) do
    method recent_plays (line 377) | defp recent_plays do
    method map_recent_play (line 382) | defp map_recent_play({id, filename, username, timestamp}) do
    method load_sound_ids_by_filename (line 391) | defp load_sound_ids_by_filename(top_sounds, recent_plays, recent_uploa...
    method load_avatars_by_username (line 408) | defp load_avatars_by_username(top_users, recent_plays, recent_uploads) do
    method recent_play_dom_id (line 425) | defp recent_play_dom_id(play) do
    method handle_event (line 431) | def handle_event("play_sound", %{"sound" => sound_name}, socket) do
    method handle_event (line 436) | def handle_event("toggle_favorite", %{"sound" => sound_name}, socket) do
    method handle_event (line 447) | def handle_event("previous_week", _, socket) do
    method handle_event (line 458) | def handle_event("next_week", _, socket) do
    method handle_event (line 469) | def handle_event("select_week", %{"week" => week_value}, socket) do
    method favorite? (line 488) | defp favorite?(favorites, sound_name, sound_ids_by_filename) do
    method get_user_avatar (line 495) | defp get_user_avatar(username, presences, avatars_by_username) do

FILE: lib/soundboard_web/live/support/flash_helpers.ex
  class SoundboardWeb.Live.Support.FlashHelpers (line 1) | defmodule SoundboardWeb.Live.Support.FlashHelpers
    method flash_sound_played (line 6) | def flash_sound_played(socket, %{filename: filename, played_by: userna...
    method clear_flash_after_timeout (line 12) | def clear_flash_after_timeout(socket) do

FILE: lib/soundboard_web/live/support/live_tags.ex
  class SoundboardWeb.Live.Support.LiveTags (line 1) | defmodule SoundboardWeb.Live.Support.LiveTags
    method search (line 16) | def search(query), do: Tags.search(query)
    method all_tags (line 17) | def all_tags(sounds), do: Tags.all_for_sounds(sounds)
    method count_sounds_with_tag (line 18) | def count_sounds_with_tag(sounds, tag), do: Tags.count_sounds_with_tag...
    method tag_selected? (line 19) | def tag_selected?(tag, selected_tags), do: Tags.tag_selected?(tag, sel...
    method update_sound_tags (line 20) | def update_sound_tags(sound, tags), do: Tags.update_sound_tags(sound, ...
    method find_or_create_tag (line 21) | def find_or_create_tag(name), do: Tags.find_or_create(name)
    method list_tags_for_sound (line 22) | def list_tags_for_sound(filename), do: Tags.list_for_sound(filename)
    method broadcast_update (line 24) | def broadcast_update do
    method validate_tag_name (line 28) | defp validate_tag_name(tag_name) do
    method validate_unique_tag (line 36) | defp validate_unique_tag(tag, current_tags) do

FILE: lib/soundboard_web/live/support/presence_live.ex
  class SoundboardWeb.Live.Support.PresenceLive (line 1) | defmodule SoundboardWeb.Live.Support.PresenceLive

FILE: lib/soundboard_web/live/support/sound_playback.ex
  class SoundboardWeb.Live.Support.SoundPlayback (line 1) | defmodule SoundboardWeb.Live.Support.SoundPlayback
    method play (line 8) | def play(socket, sound_name) do
    method current_username (line 19) | def current_username(socket) do

FILE: lib/soundboard_web/live/support/tag_form.ex
  class SoundboardWeb.Live.Support.TagForm (line 1) | defmodule SoundboardWeb.Live.Support.TagForm
    method update_input (line 27) | def update_input(socket, value, %{input_key: input_key, suggestions_ke...
    method handle_result (line 36) | defp handle_result({:ok, updated_socket}, _socket, config) do
    method handle_result (line 40) | defp handle_result({:error, message}, socket, config) do
    method reset (line 47) | defp reset(socket, %{input_key: input_key, suggestions_key: suggestion...

FILE: lib/soundboard_web/plugs/api_auth.ex
  class SoundboardWeb.Plugs.APIAuth (line 1) | defmodule SoundboardWeb.Plugs.APIAuth
    method init (line 8) | def init(opts), do: opts
    method call (line 10) | def call(conn, _opts) do
    method authenticate_with_token (line 20) | defp authenticate_with_token(conn, token) do
    method unauthorized (line 35) | defp unauthorized(conn, message \\ "Invalid API token") do
    method internal_error (line 42) | defp internal_error(conn) do
    method verify_db_token (line 49) | defp verify_db_token(token), do: ApiTokens.verify_token(token)

FILE: lib/soundboard_web/plugs/basic_auth.ex
  class SoundboardWeb.Plugs.BasicAuth (line 1) | defmodule SoundboardWeb.Plugs.BasicAuth
    method init (line 13) | def init(opts), do: opts
    method call (line 15) | def call(conn, _opts) do
    method credential (line 32) | defp credential(key) do
    method authenticate (line 46) | defp authenticate(conn, username, password) do
    method split_credentials (line 57) | defp split_credentials(decoded) do
    method unauthorized (line 64) | defp unauthorized(conn) do

FILE: lib/soundboard_web/plugs/role_check.ex
  class SoundboardWeb.Plugs.RoleCheck (line 1) | defmodule SoundboardWeb.Plugs.RoleCheck
    method init (line 9) | def init(opts), do: opts
    method call (line 11) | def call(conn, _opts) do
    method check_role (line 19) | defp check_role(conn) do
    method fresh? (line 42) | defp fresh?(nil, _interval), do: false

FILE: lib/soundboard_web/presence.ex
  class SoundboardWeb.Presence (line 1) | defmodule SoundboardWeb.Presence

FILE: lib/soundboard_web/presence_handler.ex
  class SoundboardWeb.PresenceHandler (line 1) | defmodule SoundboardWeb.PresenceHandler
    method start_link (line 31) | def start_link(_opts) do
    method init (line 36) | def init(:ok) do
    method track_presence (line 41) | def track_presence(socket, user) do
    method get_user_color (line 58) | def get_user_color(username) do
    method get_random_unique_color (line 63) | defp get_random_unique_color(username) do
    method get_presence_count (line 82) | def get_presence_count do
    method handle_presence_diff (line 88) | def handle_presence_diff(%{joins: joins, leaves: leaves}, current_coun...
    method count_active_presences (line 97) | defp count_active_presences(presences) do
    method count_active_presences (line 102) | defp count_active_presences(presences, now) do

FILE: lib/soundboard_web/router.ex
  class SoundboardWeb.Router (line 1) | defmodule SoundboardWeb.Router
    method fetch_current_user (line 94) | def fetch_current_user(conn, _) do
    method ensure_authenticated_user (line 112) | def ensure_authenticated_user(conn, _opts) do
    method put_session_opts (line 123) | defp put_session_opts(conn, _opts) do

FILE: lib/soundboard_web/sound_helpers.ex
  class SoundboardWeb.SoundHelpers (line 1) | defmodule SoundboardWeb.SoundHelpers
    method display_name (line 6) | def display_name(nil), do: ""
    method display_name (line 14) | def display_name(other), do: to_string(other)
    method slugify (line 16) | def slugify(name) do
    method ensure_slug (line 25) | defp ensure_slug(""), do: "sound"
    method ensure_slug (line 26) | defp ensure_slug(slug), do: slug

FILE: lib/soundboard_web/soundboard/sound_filter.ex
  class SoundboardWeb.Soundboard.SoundFilter (line 1) | defmodule SoundboardWeb.Soundboard.SoundFilter
    method filter_sounds (line 6) | def filter_sounds(sounds, query, selected_tags) do
    method filter_by_tags (line 12) | defp filter_by_tags(sounds, []), do: sounds
    method filter_by_tags (line 14) | defp filter_by_tags(sounds, selected_tags) do
    method filter_by_search (line 23) | defp filter_by_search(sounds, ""), do: sounds
    method filter_by_search (line 25) | defp filter_by_search(sounds, query) do

FILE: lib/soundboard_web/telemetry.ex
  class SoundboardWeb.Telemetry (line 1) | defmodule SoundboardWeb.Telemetry
    method start_link (line 5) | def start_link(arg) do
    method init (line 10) | def init(_arg) do
    method metrics (line 22) | def metrics do
    method periodic_measurements (line 85) | defp periodic_measurements do

FILE: mix.exs
  class Soundboard.MixProject (line 1) | defmodule Soundboard.MixProject
    method project (line 10) | def project do
    method application (line 53) | def application do
    method elixirc_paths (line 64) | defp elixirc_paths(:test), do: ["lib", "test/support"]
    method elixirc_paths (line 65) | defp elixirc_paths(_), do: ["lib"]
    method deps (line 70) | defp deps do
    method aliases (line 110) | defp aliases do
    method cli (line 134) | def cli do

FILE: priv/repo/migrations/20250101213201_create_sounds.exs
  class Soundboard.Repo.Migrations.CreateSounds (line 1) | defmodule Soundboard.Repo.Migrations.CreateSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250101213717_create_tags.exs
  class Soundboard.Repo.Migrations.CreateTags (line 1) | defmodule Soundboard.Repo.Migrations.CreateTags
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250101231744_create_users.exs
  class Soundboard.Repo.Migrations.CreateUsers (line 1) | defmodule Soundboard.Repo.Migrations.CreateUsers
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212120_create_plays.exs
  class Soundboard.Repo.Migrations.CreatePlays (line 1) | defmodule Soundboard.Repo.Migrations.CreatePlays
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212121_create_favorites.exs
  class Soundboard.Repo.Migrations.CreateFavorites (line 1) | defmodule Soundboard.Repo.Migrations.CreateFavorites
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212122_add_user_id_to_sounds.exs
  class Soundboard.Repo.Migrations.AddUserIdToSounds (line 1) | defmodule Soundboard.Repo.Migrations.AddUserIdToSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212123_change_favorites_filename_to_sound_id.exs
  class Soundboard.Repo.Migrations.ChangeFavoritesFilenameToSoundId (line 1) | defmodule Soundboard.Repo.Migrations.ChangeFavoritesFilenameToSoundId
    method up (line 4) | def up do
    method down (line 39) | def down do

FILE: priv/repo/migrations/20250102212124_add_index_to_plays.exs
  class Soundboard.Repo.Migrations.AddIndexToPlays (line 1) | defmodule Soundboard.Repo.Migrations.AddIndexToPlays
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212125_add_join_leave_flags_to_sounds.exs
  class Soundboard.Repo.Migrations.AddJoinLeaveFlagsToSounds (line 1) | defmodule Soundboard.Repo.Migrations.AddJoinLeaveFlagsToSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250102212126_add_url_to_sounds.exs
  class Soundboard.Repo.Migrations.AddUrlToSounds (line 1) | defmodule Soundboard.Repo.Migrations.AddUrlToSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250218214831_create_user_sound_settings.exs
  class Soundboard.Repo.Migrations.CreateUserSoundSettings (line 1) | defmodule Soundboard.Repo.Migrations.CreateUserSoundSettings
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250218214832_remove_join_leave_flags_from_sounds.exs
  class Soundboard.Repo.Migrations.RemoveJoinLeaveFlagsFromSounds (line 1) | defmodule Soundboard.Repo.Migrations.RemoveJoinLeaveFlagsFromSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250218220000_create_api_tokens.exs
  class Soundboard.Repo.Migrations.CreateApiTokens (line 1) | defmodule Soundboard.Repo.Migrations.CreateApiTokens
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250218223000_add_token_plain_to_api_tokens.exs
  class Soundboard.Repo.Migrations.AddTokenPlainToApiTokens (line 1) | defmodule Soundboard.Repo.Migrations.AddTokenPlainToApiTokens
    method change (line 4) | def change do

FILE: priv/repo/migrations/20250310120000_add_volume_to_sounds.exs
  class Soundboard.Repo.Migrations.AddVolumeToSounds (line 1) | defmodule Soundboard.Repo.Migrations.AddVolumeToSounds
    method change (line 4) | def change do

FILE: priv/repo/migrations/20260306150000_add_sound_id_to_plays.exs
  class Soundboard.Repo.Migrations.AddSoundIdToPlays (line 1) | defmodule Soundboard.Repo.Migrations.AddSoundIdToPlays
    method up (line 4) | def up do
    method down (line 22) | def down do

FILE: priv/repo/migrations/20260306151000_finalize_favorites_and_sound_tags_migrations.exs
  class Soundboard.Repo.Migrations.FinalizeFavoritesAndSoundTagsMigrations (line 1) | defmodule Soundboard.Repo.Migrations.FinalizeFavoritesAndSoundTagsMigrat...
    method up (line 4) | def up do
    method down (line 56) | def down do

FILE: priv/repo/migrations/20260307211000_rename_sound_name_to_played_filename_in_plays.exs
  class Soundboard.Repo.Migrations.RenameSoundNameToPlayedFilenameInPlays (line 1) | defmodule Soundboard.Repo.Migrations.RenameSoundNameToPlayedFilenameInPlays
    method up (line 4) | def up do
    method down (line 22) | def down do

FILE: test/soundboard/accounts/api_tokens_test.exs
  class Soundboard.Accounts.ApiTokensTest (line 1) | defmodule Soundboard.Accounts.ApiTokensTest

FILE: test/soundboard/accounts_test.exs
  class Soundboard.AccountsTest (line 1) | defmodule Soundboard.AccountsTest

FILE: test/soundboard/audio_player/playback_engine_test.exs
  class Soundboard.AudioPlayer.PlaybackEngineTest (line 1) | defmodule Soundboard.AudioPlayer.PlaybackEngineTest

FILE: test/soundboard/audio_player/playback_queue_test.exs
  class Soundboard.AudioPlayer.PlaybackQueueTest (line 1) | defmodule Soundboard.AudioPlayer.PlaybackQueueTest
    method base_state (line 9) | defp base_state(overrides \\ []) do
    method request (line 26) | defp request(overrides \\ %{}) do

FILE: test/soundboard/audio_player/sound_library_test.exs
  class Soundboard.AudioPlayer.SoundLibraryTest (line 1) | defmodule Soundboard.AudioPlayer.SoundLibraryTest
    method insert_sound! (line 122) | defp insert_sound!(user, attrs) do
    method unique_filename (line 133) | defp unique_filename(prefix, ext) do
    method clear_sound_cache (line 137) | defp clear_sound_cache do

FILE: test/soundboard/discord/bot_identity_test.exs
  class Soundboard.Discord.BotIdentityTest (line 1) | defmodule Soundboard.Discord.BotIdentityTest

FILE: test/soundboard/discord/handler/auto_join_policy_test.exs
  class Soundboard.Discord.Handler.AutoJoinPolicyTest (line 1) | defmodule Soundboard.Discord.Handler.AutoJoinPolicyTest

FILE: test/soundboard/discord/handler/command_handler_test.exs
  class Soundboard.Discord.Handler.CommandHandlerTest (line 1) | defmodule Soundboard.Discord.Handler.CommandHandlerTest

FILE: test/soundboard/discord/handler/idle_timeout_policy_test.exs
  class Soundboard.Discord.Handler.IdleTimeoutPolicyTest (line 1) | defmodule Soundboard.Discord.Handler.IdleTimeoutPolicyTest

FILE: test/soundboard/discord/handler/voice_presence_test.exs
  class Soundboard.Discord.Handler.VoicePresenceTest (line 1) | defmodule Soundboard.Discord.Handler.VoicePresenceTest

FILE: test/soundboard/discord/handler/voice_runtime_test.exs
  class Soundboard.Discord.Handler.VoiceRuntimeTest (line 1) | defmodule Soundboard.Discord.Handler.VoiceRuntimeTest

FILE: test/soundboard/discord/role_checker_test.exs
  class Soundboard.Discord.RoleCheckerTest (line 1) | defmodule Soundboard.Discord.RoleCheckerTest
    method restore_env (line 21) | defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)
    method restore_env (line 22) | defp restore_env(key, value), do: Application.put_env(:soundboard, key...

FILE: test/soundboard/discord/runtime_capability_test.exs
  class Soundboard.Discord.RuntimeCapabilityTest (line 1) | defmodule Soundboard.Discord.RuntimeCapabilityTest

FILE: test/soundboard/discord/voice_test.exs
  class Soundboard.Discord.VoiceTest (line 1) | defmodule Soundboard.Discord.VoiceTest
  class VoiceModuleWithPlay4 (line 6) | defmodule VoiceModuleWithPlay4
    method play (line 7) | def play(guild_id, input, type, opts) do
  class VoiceModuleWithPlay3 (line 13) | defmodule VoiceModuleWithPlay3
    method play (line 14) | def play(guild_id, input, type) do

FILE: test/soundboard/favorites_test.exs
  class Soundboard.FavoritesTest (line 1) | defmodule Soundboard.FavoritesTest
    method insert_user (line 66) | defp insert_user(attrs \\ %{}) do
    method insert_sound (line 84) | defp insert_sound(user, attrs \\ %{}) do

FILE: test/soundboard/migrations/data_migrations_test.exs
  class Soundboard.Migrations.DataMigrationsTest (line 16) | defmodule Soundboard.Migrations.DataMigrationsTest
    method migrate_up (line 210) | defp migrate_up(repo, migrations) do
    method column_names (line 216) | defp column_names(repo, table_name) do
  class MigrationRepo (line 32) | defmodule MigrationRepo

FILE: test/soundboard/public_url_test.exs
  class Soundboard.PublicURLTest (line 1) | defmodule Soundboard.PublicURLTest

FILE: test/soundboard/pubsub_topics_test.exs
  class Soundboard.PubSubTopicsTest (line 1) | defmodule Soundboard.PubSubTopicsTest

FILE: test/soundboard/sound_tag_test.exs
  class Soundboard.SoundTagTest (line 1) | defmodule Soundboard.SoundTagTest
    method insert_sound (line 49) | defp insert_sound do
    method insert_tag (line 62) | defp insert_tag do
    method insert_user (line 71) | defp insert_user do

FILE: test/soundboard/sound_test.exs
  class Soundboard.SoundTest (line 1) | defmodule Soundboard.SoundTest
    method insert_user (line 543) | defp insert_user do
    method insert_sound (line 556) | defp insert_sound(user) do

FILE: test/soundboard/sounds/management_test.exs
  class Soundboard.Sounds.ManagementTest (line 1) | defmodule Soundboard.Sounds.ManagementTest
    method insert_local_sound (line 140) | defp insert_local_sound(user, filename) do
    method uploads_dir (line 154) | defp uploads_dir do

FILE: test/soundboard/sounds/sound_settings_test.exs
  class Soundboard.Sounds.SoundSettingsTest (line 1) | defmodule Soundboard.Sounds.SoundSettingsTest
    method insert_user (line 166) | defp insert_user do
    method insert_sound (line 179) | defp insert_sound(user) do
    method insert_sound! (line 184) | defp insert_sound!(user, filename) do

FILE: test/soundboard/sounds/tags_test.exs
  class Soundboard.Sounds.TagsTest (line 1) | defmodule Soundboard.Sounds.TagsTest
    method insert_tag! (line 103) | defp insert_tag!(name) do

FILE: test/soundboard/sounds/uploads_test.exs
  class Soundboard.Sounds.UploadsTest (line 1) | defmodule Soundboard.Sounds.UploadsTest
    method request (line 222) | defp request(user, attrs) do
    method uploads_dir (line 226) | defp uploads_dir do

FILE: test/soundboard/stats_test.exs
  class Soundboard.StatsTest (line 1) | defmodule Soundboard.StatsTest
    method insert_sound (line 143) | defp insert_sound(user) do
    method insert_user (line 156) | defp insert_user do

FILE: test/soundboard/tags/tag_test.exs
  class Soundboard.Tags.TagTest (line 1) | defmodule Soundboard.Tags.TagTest
    method insert_user (line 78) | defp insert_user do
    method insert_sound (line 91) | defp insert_sound(user) do

FILE: test/soundboard/uploads_path_test.exs
  class Soundboard.UploadsPathTest (line 1) | defmodule Soundboard.UploadsPathTest

FILE: test/soundboard/volume_test.exs
  class Soundboard.VolumeTest (line 1) | defmodule Soundboard.VolumeTest

FILE: test/soundboard_test.exs
  class SoundboardTest (line 1) | defmodule SoundboardTest

FILE: test/soundboard_web/audio_player_test.exs
  class Soundboard.AudioPlayerTest (line 1) | defmodule Soundboard.AudioPlayerTest

FILE: test/soundboard_web/components/layouts/navbar_test.exs
  class SoundboardWeb.Components.Layouts.NavbarTest (line 1) | defmodule SoundboardWeb.Components.Layouts.NavbarTest

FILE: test/soundboard_web/components/soundboard/edit_modal_test.exs
  class SoundboardWeb.Components.Soundboard.EditModalTest (line 1) | defmodule SoundboardWeb.Components.Soundboard.EditModalTest
    method edit_assigns (line 47) | defp edit_assigns(overrides \\ %{}) do
    method edit_sound (line 60) | defp edit_sound do

FILE: test/soundboard_web/components/soundboard/upload_modal_test.exs
  class SoundboardWeb.Components.Soundboard.UploadModalTest (line 1) | defmodule SoundboardWeb.Components.Soundboard.UploadModalTest
    method upload_assigns (line 47) | defp upload_assigns(overrides \\ %{}) do

FILE: test/soundboard_web/controllers/api/sound_controller_test.exs
  class SoundboardWeb.API.SoundControllerTest (line 1) | defmodule SoundboardWeb.API.SoundControllerTest
    method insert_sound (line 330) | defp insert_sound(user) do
    method insert_tag (line 343) | defp insert_tag do
    method insert_user (line 352) | defp insert_user do
    method insert_sound_tag (line 365) | defp insert_sound_tag(sound, tag) do
    method uploads_dir (line 375) | defp uploads_dir do
    method temp_upload_path (line 379) | defp temp_upload_path(filename) do

FILE: test/soundboard_web/controllers/auth_controller_test.exs
  class SoundboardWeb.AuthControllerTest (line 1) | defmodule SoundboardWeb.AuthControllerTest
    method insert_user (line 219) | defp insert_user do

FILE: test/soundboard_web/controllers/upload_controller_test.exs
  class SoundboardWeb.UploadControllerTest (line 1) | defmodule SoundboardWeb.UploadControllerTest

FILE: test/soundboard_web/discord_handler_test.exs
  class Soundboard.Discord.HandlerTest (line 1) | defmodule Soundboard.Discord.HandlerTest
    method insert_user! (line 280) | defp insert_user!(attrs) do
    method insert_sound! (line 286) | defp insert_sound!(user, attrs) do
    method insert_user_sound_setting! (line 298) | defp insert_user_sound_setting!(user, sound, attrs) do

FILE: test/soundboard_web/eda_consumer_test.exs
  class Soundboard.Discord.ConsumerTest (line 1) | defmodule Soundboard.Discord.ConsumerTest

FILE: test/soundboard_web/live/favorites_live_test.exs
  class SoundboardWeb.FavoritesLiveTest (line 1) | defmodule SoundboardWeb.FavoritesLiveTest

FILE: test/soundboard_web/live/settings_live_test.exs
  class SoundboardWeb.SettingsLiveTest (line 1) | defmodule SoundboardWeb.SettingsLiveTest

FILE: test/soundboard_web/live/soundboard_live/edit_flow_test.exs
  class SoundboardWeb.Live.SoundboardLive.EditFlowTest (line 1) | defmodule SoundboardWeb.Live.SoundboardLive.EditFlowTest
    method create_user (line 29) | defp create_user do
    method seed_alphabetical_tags (line 39) | defp seed_alphabetical_tags do

FILE: test/soundboard_web/live/soundboard_live/upload_flow_test.exs
  class SoundboardWeb.Live.SoundboardLive.UploadFlowTest (line 1) | defmodule SoundboardWeb.Live.SoundboardLive.UploadFlowTest
    method build_socket (line 53) | defp build_socket(overrides) do
    method seed_alphabetical_tags (line 79) | defp seed_alphabetical_tags do

FILE: test/soundboard_web/live/soundboard_live_test.exs
  class SoundboardWeb.SoundboardLiveTest (line 1) | defmodule SoundboardWeb.SoundboardLiveTest
    method uploads_dir (line 463) | defp uploads_dir do

FILE: test/soundboard_web/live/stats_live_test.exs
  class SoundboardWeb.StatsLiveTest (line 1) | defmodule SoundboardWeb.StatsLiveTest
    method live_as_user (line 141) | defp live_as_user(conn, user) do

FILE: test/soundboard_web/plugs/api_auth_db_token_test.exs
  class SoundboardWeb.APIAuthDBTokenTest (line 1) | defmodule SoundboardWeb.APIAuthDBTokenTest

FILE: test/soundboard_web/plugs/basic_auth_test.exs
  class SoundboardWeb.BasicAuthPlugTest (line 1) | defmodule SoundboardWeb.BasicAuthPlugTest
    method restore_env (line 23) | defp restore_env(key, nil), do: System.delete_env(key)
    method restore_env (line 24) | defp restore_env(key, value), do: System.put_env(key, value)

FILE: test/soundboard_web/plugs/role_check_test.exs
  class SoundboardWeb.Plugs.RoleCheckTest (line 1) | defmodule SoundboardWeb.Plugs.RoleCheckTest
    method restore_env (line 25) | defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)
    method restore_env (line 26) | defp restore_env(key, value), do: Application.put_env(:soundboard, key...
    method insert_user (line 28) | defp insert_user do
    method build_conn_with_session (line 41) | defp build_conn_with_session(conn, user, session_params) do

FILE: test/soundboard_web/presence_handler_test.exs
  class SoundboardWeb.PresenceHandlerTest (line 1) | defmodule SoundboardWeb.PresenceHandlerTest

FILE: test/soundboard_web/sound_helpers_test.exs
  class SoundboardWeb.SoundHelpersTest (line 1) | defmodule SoundboardWeb.SoundHelpersTest

FILE: test/soundboard_web/soundboard/sound_filter_test.exs
  class SoundboardWeb.Soundboard.SoundFilterTest (line 1) | defmodule SoundboardWeb.Soundboard.SoundFilterTest

FILE: test/support/conn_case.ex
  class SoundboardWeb.ConnCase (line 1) | defmodule SoundboardWeb.ConnCase
    method file_upload (line 30) | def file_upload(lv, field, entries) do

FILE: test/support/data_case.ex
  class Soundboard.DataCase (line 1) | defmodule Soundboard.DataCase
    method setup_sandbox (line 27) | def setup_sandbox(tags) do
    method errors_on (line 40) | def errors_on(changeset) do

FILE: test/support/test_helpers.ex
  class Soundboard.TestHelpers (line 1) | defmodule Soundboard.TestHelpers
    method create_test_file (line 7) | def create_test_file(filename) do
    method cleanup_test_files (line 15) | def cleanup_test_files do
    method setup_test_socket (line 19) | def setup_test_socket(assigns \\ %{}) do
    method setup_upload_socket (line 34) | def setup_upload_socket(user) do
    method setup_test_audio_file (line 51) | def setup_test_audio_file do
    method create_user (line 59) | def create_user(attrs \\ %{}) do
    method create_sound (line 72) | def create_sound(user, attrs \\ %{}) do
Condensed preview — 199 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (688K chars).
[
  {
    "path": ".credo.exs",
    "chars": 7743,
    "preview": "%{\n  configs: [\n    %{\n      name: \"default\",\n      files: %{\n        included: [\n          \"lib/\",\n          \"src/\",\n  "
  },
  {
    "path": ".dockerignore",
    "chars": 81,
    "preview": ".dockerignore\n.git*\ntest\n.env*\n*.md\ncoveralls.json\ndocker-compose.yml\nDockerfile\n"
  },
  {
    "path": ".ex_dna.exs",
    "chars": 148,
    "preview": "%{\n  min_mass: 25,\n  ignore: [\"lib/soundboard_web/templates/**\"],\n  excluded_macros: [:@, :schema, :pipe_through, :plug]"
  },
  {
    "path": ".formatter.exs",
    "chars": 225,
    "preview": "[\n  import_deps: [:ecto, :ecto_sql, :phoenix],\n  subdirectories: [\"priv/*/migrations\"],\n  plugins: [Phoenix.LiveView.HTM"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 485,
    "preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 4217,
    "preview": "name: CI/CD Pipeline\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\njobs:\n  test:\n    n"
  },
  {
    "path": ".gitignore",
    "chars": 1222,
    "preview": "# The directory Mix will write compiled artifacts to.\n/_build/\n\n/reports/\n\n# If you run \"mix test --cover\", coverage ass"
  },
  {
    "path": ".tool-versions",
    "chars": 33,
    "preview": "erlang 27.2\nelixir 1.19.0-otp-27\n"
  },
  {
    "path": "AGENTS.md",
    "chars": 2265,
    "preview": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- Source: `lib/soundboard` (core), `lib/soundboard_w"
  },
  {
    "path": "Dockerfile",
    "chars": 1356,
    "preview": "# syntax=docker/dockerfile:1\nFROM elixir:1.19-alpine AS build\n\nARG MIX_ENV=prod\n\nENV MIX_ENV=$MIX_ENV \\\n    MIX_HOME=/ap"
  },
  {
    "path": "README.md",
    "chars": 12807,
    "preview": "# Soundbored\n[![Coverage Status](https://coveralls.io/repos/github/christomitov/soundbored/badge.svg?branch=main)](https"
  },
  {
    "path": "assets/css/app.css",
    "chars": 1599,
    "preview": "@import \"tailwindcss/base\";\n@import \"tailwindcss/components\";\n@import \"tailwindcss/utilities\";\n\n.flash-message {\n  opaci"
  },
  {
    "path": "assets/js/app.js",
    "chars": 18103,
    "preview": "// If you want to use Phoenix channels, run `mix help phx.gen.channel`\n// to get started and then uncomment the line bel"
  },
  {
    "path": "assets/js/hooks/local_player.js",
    "chars": 2291,
    "preview": "const LocalPlayer = {\n  currentAudio: null,\n  currentButton: null,\n\n  mounted() {\n    this.el.addEventListener(\"click\", "
  },
  {
    "path": "assets/tailwind.config.js",
    "chars": 2852,
    "preview": "// See the Tailwind configuration guide for advanced usage\n// https://tailwindcss.com/docs/configuration\n\nconst plugin ="
  },
  {
    "path": "assets/vendor/topbar.js",
    "chars": 5374,
    "preview": "/**\n * @license MIT\n * topbar 2.0.0, 2023-02-04\n * https://buunguyen.github.io/topbar\n * Copyright (c) 2021 Buu Nguyen\n "
  },
  {
    "path": "config/config.exs",
    "chars": 2701,
    "preview": "# This file is responsible for configuring your application\n# and its dependencies with the aid of the Config module.\n#\n"
  },
  {
    "path": "config/dev.exs",
    "chars": 1608,
    "preview": "import Config\n\nconfig :soundboard, Soundboard.Repo,\n  database: \"database.db\",\n  adapter: Ecto.Adapters.SQLite3\n\ngenerat"
  },
  {
    "path": "config/prod.exs",
    "chars": 815,
    "preview": "import Config\n\n# Note we also include the path to a cache manifest\n# containing the digested version of static files. Th"
  },
  {
    "path": "config/runtime.exs",
    "chars": 8009,
    "preview": "import Config\nimport Dotenvy\n\nenv_dir_prefix = System.get_env(\"RELEASE_ROOT\") || Path.expand(\".\")\n\nsource!([\n  Path.absn"
  },
  {
    "path": "config/test.exs",
    "chars": 1918,
    "preview": "import Config\n\n# Configure your database\n#\n# The MIX_TEST_PARTITION environment variable can be used\n# to provide built-"
  },
  {
    "path": "coveralls.json",
    "chars": 1026,
    "preview": "{\n  \"skip_files\": [\n    \"lib/soundboard_web/presence.ex\",\n    \"lib/soundboard_web/telemetry.ex\",\n    \"lib/soundboard_web"
  },
  {
    "path": "docker-compose.yml",
    "chars": 846,
    "preview": "services:\n  soundbored:\n    image: christom/soundbored:latest\n    ports:\n      - \"127.0.0.1:4000:4000\"\n    env_file: .en"
  },
  {
    "path": "docs/plans/discord-role-gated-access.md",
    "chars": 1541,
    "preview": "# Discord Role-Gated Access Implementation Plan\n\n**Status:** Complete\n**Spec:** `docs/specs/discord-role-gated-access.md"
  },
  {
    "path": "docs/plans/discord-role-gated-access.md.tasks.json",
    "chars": 3831,
    "preview": "{\n  \"planPath\": \"docs/plans/discord-role-gated-access.md\",\n  \"tasks\": [\n    {\n      \"id\": 2,\n      \"subject\": \"Task 1: A"
  },
  {
    "path": "docs/specs/discord-role-gated-access.md",
    "chars": 2704,
    "preview": "# Spec: Discord Role-Gated Access\n\n**Status:** Implemented\n**Date:** 2026-04-27\n\n---\n\n## Summary\n\nRestrict web access to"
  },
  {
    "path": "docs/specs/voice-auto-join-idle-leave.md",
    "chars": 14529,
    "preview": "# Spec: Voice Channel Auto-Join on Playback & Idle Auto-Leave\n\n**Status:** Implemented  \n**Date:** 2026-04-29  \n**Author"
  },
  {
    "path": "docs/specs/youtube-playback.md",
    "chars": 11057,
    "preview": "# Spec: YouTube Video Playback via Discord Bot Command\n\n**Status:** Draft  \n**Date:** 2026-03-07  \n**Author:** —\n\n---\n\n#"
  },
  {
    "path": "entrypoint.sh",
    "chars": 242,
    "preview": "#!/bin/sh\n\n# Run migrations\necho \"Running database migrations...\"\nmix ecto.migrate\n\n# Start Phoenix server in foreground"
  },
  {
    "path": "lib/soundboard/accounts/api_token.ex",
    "chars": 1300,
    "preview": "defmodule Soundboard.Accounts.ApiToken do\n  @moduledoc \"\"\"\n  API access token bound to a user.\n\n  The token hash is used"
  },
  {
    "path": "lib/soundboard/accounts/api_tokens.ex",
    "chars": 3085,
    "preview": "defmodule Soundboard.Accounts.ApiTokens do\n  @moduledoc \"\"\"\n  Context for managing API tokens bound to users.\n  \"\"\"\n  re"
  },
  {
    "path": "lib/soundboard/accounts/user.ex",
    "chars": 725,
    "preview": "defmodule Soundboard.Accounts.User do\n  @moduledoc \"\"\"\n  The User module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changese"
  },
  {
    "path": "lib/soundboard/accounts.ex",
    "chars": 483,
    "preview": "defmodule Soundboard.Accounts do\n  @moduledoc \"\"\"\n  Accounts boundary helpers used by web and runtime code.\n  \"\"\"\n\n  ali"
  },
  {
    "path": "lib/soundboard/application.ex",
    "chars": 1193,
    "preview": "defmodule Soundboard.Application do\n  # See https://hexdocs.pm/elixir/Application.html\n  # for more information on OTP A"
  },
  {
    "path": "lib/soundboard/audio_player/notifier.ex",
    "chars": 291,
    "preview": "defmodule Soundboard.AudioPlayer.Notifier do\n  @moduledoc false\n\n  alias Soundboard.PubSubTopics\n\n  def sound_played(sou"
  },
  {
    "path": "lib/soundboard/audio_player/playback_engine.ex",
    "chars": 12770,
    "preview": "defmodule Soundboard.AudioPlayer.PlaybackEngine do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.Accounts.Use"
  },
  {
    "path": "lib/soundboard/audio_player/playback_queue.ex",
    "chars": 7422,
    "preview": "defmodule Soundboard.AudioPlayer.PlaybackQueue do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer.{"
  },
  {
    "path": "lib/soundboard/audio_player/sound_library.ex",
    "chars": 2996,
    "preview": "defmodule Soundboard.AudioPlayer.SoundLibrary do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.Sound\n\n  def e"
  },
  {
    "path": "lib/soundboard/audio_player/voice_session.ex",
    "chars": 3372,
    "preview": "defmodule Soundboard.AudioPlayer.VoiceSession do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer.St"
  },
  {
    "path": "lib/soundboard/audio_player.ex",
    "chars": 9334,
    "preview": "defmodule Soundboard.AudioPlayer do\n  @moduledoc \"\"\"\n  Handles audio playback coordination.\n  \"\"\"\n\n  use GenServer\n\n  re"
  },
  {
    "path": "lib/soundboard/discord/bot_identity.ex",
    "chars": 543,
    "preview": "defmodule Soundboard.Discord.BotIdentity do\n  @moduledoc false\n\n  alias EDA.API.User\n  alias EDA.Cache\n\n  def fetch do\n "
  },
  {
    "path": "lib/soundboard/discord/consumer.ex",
    "chars": 264,
    "preview": "defmodule Soundboard.Discord.Consumer do\n  @moduledoc false\n  @behaviour EDA.Consumer\n\n  alias Soundboard.Discord.Handle"
  },
  {
    "path": "lib/soundboard/discord/guild_cache.ex",
    "chars": 1700,
    "preview": "defmodule Soundboard.Discord.GuildCache do\n  @moduledoc false\n\n  alias EDA.Cache\n\n  def all do\n    Cache.guilds()\n    |>"
  },
  {
    "path": "lib/soundboard/discord/handler/auto_join_policy.ex",
    "chars": 535,
    "preview": "defmodule Soundboard.Discord.Handler.AutoJoinPolicy do\n  @moduledoc false\n\n  @type mode :: :presence | :play | false\n\n  "
  },
  {
    "path": "lib/soundboard/discord/handler/command_handler.ex",
    "chars": 988,
    "preview": "defmodule Soundboard.Discord.Handler.CommandHandler do\n  @moduledoc false\n\n  alias Soundboard.Discord.Handler.VoiceRunti"
  },
  {
    "path": "lib/soundboard/discord/handler/idle_timeout_policy.ex",
    "chars": 666,
    "preview": "defmodule Soundboard.Discord.Handler.IdleTimeoutPolicy do\n  @moduledoc false\n\n  require Logger\n\n  @default_seconds 600\n\n"
  },
  {
    "path": "lib/soundboard/discord/handler/sound_effects.ex",
    "chars": 1861,
    "preview": "defmodule Soundboard.Discord.Handler.SoundEffects do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.{AudioPlay"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_commands.ex",
    "chars": 2830,
    "preview": "defmodule Soundboard.Discord.Handler.VoiceCommands do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlay"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_presence.ex",
    "chars": 4592,
    "preview": "defmodule Soundboard.Discord.Handler.VoicePresence do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlay"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_runtime.ex",
    "chars": 8191,
    "preview": "defmodule Soundboard.Discord.Handler.VoiceRuntime do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlaye"
  },
  {
    "path": "lib/soundboard/discord/handler.ex",
    "chars": 4855,
    "preview": "defmodule Soundboard.Discord.Handler do\n  @moduledoc \"\"\"\n  Handles the Discord events.\n  \"\"\"\n  use GenServer\n  require L"
  },
  {
    "path": "lib/soundboard/discord/message.ex",
    "chars": 314,
    "preview": "defmodule Soundboard.Discord.Message do\n  @moduledoc false\n\n  alias EDA.API.Message, as: EDAMessage\n\n  def create(channe"
  },
  {
    "path": "lib/soundboard/discord/role_checker.ex",
    "chars": 1854,
    "preview": "defmodule Soundboard.Discord.RoleChecker do\n  @moduledoc false\n  require Logger\n\n  alias EDA.API.Member\n\n  @doc \"\"\"\n  Ch"
  },
  {
    "path": "lib/soundboard/discord/runtime_capability.ex",
    "chars": 1073,
    "preview": "defmodule Soundboard.Discord.RuntimeCapability do\n  @moduledoc false\n\n  require Logger\n\n  alias EDA.Voice.Dave.Native\n\n "
  },
  {
    "path": "lib/soundboard/discord/voice.ex",
    "chars": 2319,
    "preview": "defmodule Soundboard.Discord.Voice do\n  @moduledoc false\n\n  require Logger\n\n  alias EDA.Voice, as: EDAVoice\n\n  @connecte"
  },
  {
    "path": "lib/soundboard/favorites/favorite.ex",
    "chars": 574,
    "preview": "defmodule Soundboard.Favorites.Favorite do\n  @moduledoc \"\"\"\n  The Favorite module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto"
  },
  {
    "path": "lib/soundboard/favorites.ex",
    "chars": 2482,
    "preview": "defmodule Soundboard.Favorites do\n  @moduledoc \"\"\"\n  The Favorites module.\n  \"\"\"\n\n  import Ecto.Query\n\n  alias Soundboar"
  },
  {
    "path": "lib/soundboard/public_url.ex",
    "chars": 834,
    "preview": "defmodule Soundboard.PublicURL do\n  @moduledoc \"\"\"\n  Shared helper for the application's externally visible base URL.\n\n "
  },
  {
    "path": "lib/soundboard/pubsub_topics.ex",
    "chars": 1092,
    "preview": "defmodule Soundboard.PubSubTopics do\n  @moduledoc false\n\n  alias Phoenix.PubSub\n\n  @files_topic \"soundboard.files\"\n  @pl"
  },
  {
    "path": "lib/soundboard/release.ex",
    "chars": 604,
    "preview": "defmodule Soundboard.Release do\n  @moduledoc false\n\n  @app :soundboard\n\n  def migrate do\n    load_app()\n\n    for repo <-"
  },
  {
    "path": "lib/soundboard/repo.ex",
    "chars": 111,
    "preview": "defmodule Soundboard.Repo do\n  use Ecto.Repo,\n    otp_app: :soundboard,\n    adapter: Ecto.Adapters.SQLite3\nend\n"
  },
  {
    "path": "lib/soundboard/sound.ex",
    "chars": 2153,
    "preview": "defmodule Soundboard.Sound do\n  @moduledoc \"\"\"\n  Sound schema.\n  \"\"\"\n\n  use Ecto.Schema\n  import Ecto.Changeset\n  import"
  },
  {
    "path": "lib/soundboard/sound_tag.ex",
    "chars": 645,
    "preview": "defmodule Soundboard.SoundTag do\n  @moduledoc \"\"\"\n  The SoundTag module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset"
  },
  {
    "path": "lib/soundboard/sounds/management.ex",
    "chars": 4011,
    "preview": "defmodule Soundboard.Sounds.Management do\n  @moduledoc \"\"\"\n  Domain-level sound update/delete operations used by LiveVie"
  },
  {
    "path": "lib/soundboard/sounds/tags.ex",
    "chars": 2501,
    "preview": "defmodule Soundboard.Sounds.Tags do\n  @moduledoc \"\"\"\n  Domain helpers for searching, resolving, and persisting sound tag"
  },
  {
    "path": "lib/soundboard/sounds/uploads/create_request.ex",
    "chars": 2246,
    "preview": "defmodule Soundboard.Sounds.Uploads.CreateRequest do\n  @moduledoc false\n\n  alias Soundboard.Accounts.User\n\n  @enforce_ke"
  },
  {
    "path": "lib/soundboard/sounds/uploads/creator.ex",
    "chars": 1805,
    "preview": "defmodule Soundboard.Sounds.Uploads.Creator do\n  @moduledoc false\n\n  alias Soundboard.{PubSubTopics, Repo, Sound, Stats,"
  },
  {
    "path": "lib/soundboard/sounds/uploads/normalizer.ex",
    "chars": 3359,
    "preview": "defmodule Soundboard.Sounds.Uploads.Normalizer do\n  @moduledoc false\n\n  import Ecto.Changeset\n\n  alias Soundboard.{Sound"
  },
  {
    "path": "lib/soundboard/sounds/uploads/source.ex",
    "chars": 4906,
    "preview": "defmodule Soundboard.Sounds.Uploads.Source do\n  @moduledoc false\n\n  import Ecto.Changeset\n  import Ecto.Query\n\n  require"
  },
  {
    "path": "lib/soundboard/sounds/uploads.ex",
    "chars": 1883,
    "preview": "defmodule Soundboard.Sounds.Uploads do\n  @moduledoc \"\"\"\n  Canonical sound upload/create API.\n  \"\"\"\n\n  import Ecto.Change"
  },
  {
    "path": "lib/soundboard/sounds.ex",
    "chars": 6036,
    "preview": "defmodule Soundboard.Sounds do\n  @moduledoc \"\"\"\n  Sound domain context.\n  \"\"\"\n\n  import Ecto.Query\n\n  alias Soundboard.A"
  },
  {
    "path": "lib/soundboard/stats/play.ex",
    "chars": 526,
    "preview": "defmodule Soundboard.Stats.Play do\n  @moduledoc \"\"\"\n  The Play module.\n  \"\"\"\n\n  use Ecto.Schema\n  import Ecto.Changeset\n"
  },
  {
    "path": "lib/soundboard/stats.ex",
    "chars": 3068,
    "preview": "defmodule Soundboard.Stats do\n  @moduledoc \"\"\"\n  Handles the stats of the soundboard.\n  \"\"\"\n\n  import Ecto.Query\n  impor"
  },
  {
    "path": "lib/soundboard/tag.ex",
    "chars": 661,
    "preview": "defmodule Soundboard.Tag do\n  @moduledoc \"\"\"\n  The Tag module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n  import "
  },
  {
    "path": "lib/soundboard/uploads_path.ex",
    "chars": 1461,
    "preview": "defmodule Soundboard.UploadsPath do\n  @moduledoc \"\"\"\n  Central source of truth for uploaded sound storage paths.\n  \"\"\"\n\n"
  },
  {
    "path": "lib/soundboard/user_sound_setting.ex",
    "chars": 1568,
    "preview": "defmodule Soundboard.UserSoundSetting do\n  @moduledoc \"\"\"\n  The UserSoundSetting module.\n  \"\"\"\n  use Ecto.Schema\n  impor"
  },
  {
    "path": "lib/soundboard/volume.ex",
    "chars": 2127,
    "preview": "defmodule Soundboard.Volume do\n  @moduledoc \"\"\"\n  Helpers for working with volume percentages and decimal ratios.\n  \"\"\"\n"
  },
  {
    "path": "lib/soundboard.ex",
    "chars": 339,
    "preview": "defmodule Soundboard do\n  @moduledoc \"\"\"\n  Soundboard keeps the contexts that define your domain\n  and business logic.\n\n"
  },
  {
    "path": "lib/soundboard_web/components/core_components.ex",
    "chars": 21514,
    "preview": "defmodule SoundboardWeb.CoreComponents do\n  @moduledoc \"\"\"\n  Provides core UI components.\n\n  At first glance, this modul"
  },
  {
    "path": "lib/soundboard_web/components/flash_component.ex",
    "chars": 788,
    "preview": "defmodule SoundboardWeb.Components.FlashComponent do\n  @moduledoc \"\"\"\n  The flash component.\n  \"\"\"\n  use Phoenix.Compone"
  },
  {
    "path": "lib/soundboard_web/components/layouts/app.html.heex",
    "chars": 317,
    "preview": "<header class=\"h-16\">\n  <.live_component\n    module={SoundboardWeb.Components.Layouts.Navbar}\n    id=\"navbar\"\n    curren"
  },
  {
    "path": "lib/soundboard_web/components/layouts/navbar.ex",
    "chars": 8010,
    "preview": "defmodule SoundboardWeb.Components.Layouts.Navbar do\n  @moduledoc \"\"\"\n  The navbar component.\n  \"\"\"\n  use Phoenix.LiveCo"
  },
  {
    "path": "lib/soundboard_web/components/layouts/root.html.heex",
    "chars": 1084,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\" class=\"[scrollbar-gutter:stable]\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name="
  },
  {
    "path": "lib/soundboard_web/components/layouts.ex",
    "chars": 116,
    "preview": "defmodule SoundboardWeb.Layouts do\n  @moduledoc false\n  use SoundboardWeb, :html\n\n  embed_templates \"layouts/*\"\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/delete_modal.ex",
    "chars": 1780,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.DeleteModal do\n  @moduledoc \"\"\"\n  The delete modal component.\n  \"\"\"\n  use "
  },
  {
    "path": "lib/soundboard_web/components/soundboard/edit_modal.ex",
    "chars": 9822,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.EditModal do\n  @moduledoc \"\"\"\n  The edit modal component.\n  \"\"\"\n  use Phoe"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/helpers.ex",
    "chars": 360,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.Helpers do\n  @moduledoc \"\"\"\n  Helper functions for the soundboard.\n  \"\"\"\n "
  },
  {
    "path": "lib/soundboard_web/components/soundboard/tag_components.ex",
    "chars": 4752,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.TagComponents do\n  @moduledoc \"\"\"\n  Shared tag UI helpers for the soundboa"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/upload_modal.ex",
    "chars": 12519,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.UploadModal do\n  @moduledoc \"\"\"\n  The upload modal component.\n  \"\"\"\n  use "
  },
  {
    "path": "lib/soundboard_web/components/soundboard/volume_control.ex",
    "chars": 2186,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.VolumeControl do\n  @moduledoc \"\"\"\n  Shared volume slider with preview supp"
  },
  {
    "path": "lib/soundboard_web/controllers/api/sound_controller.ex",
    "chars": 3591,
    "preview": "defmodule SoundboardWeb.API.SoundController do\n  use SoundboardWeb, :controller\n\n  alias Soundboard.{Repo, Sound, Sounds"
  },
  {
    "path": "lib/soundboard_web/controllers/auth_controller.ex",
    "chars": 1944,
    "preview": "defmodule SoundboardWeb.AuthController do\n  use SoundboardWeb, :controller\n\n  plug Ueberauth\n\n  alias Soundboard.Account"
  },
  {
    "path": "lib/soundboard_web/controllers/error_html.ex",
    "chars": 236,
    "preview": "defmodule SoundboardWeb.ErrorHTML do\n  @moduledoc \"\"\"\n  Renders fallback HTML error messages.\n  \"\"\"\n  use SoundboardWeb,"
  },
  {
    "path": "lib/soundboard_web/controllers/error_json.ex",
    "chars": 231,
    "preview": "defmodule SoundboardWeb.ErrorJSON do\n  @moduledoc \"\"\"\n  Renders fallback JSON error payloads.\n  \"\"\"\n\n  def render(templa"
  },
  {
    "path": "lib/soundboard_web/controllers/upload_controller.ex",
    "chars": 459,
    "preview": "defmodule SoundboardWeb.UploadController do\n  use SoundboardWeb, :controller\n\n  alias Soundboard.UploadsPath\n\n  def show"
  },
  {
    "path": "lib/soundboard_web/endpoint.ex",
    "chars": 1634,
    "preview": "defmodule SoundboardWeb.Endpoint do\n  use Phoenix.Endpoint, otp_app: :soundboard\n\n  # The session will be stored in the "
  },
  {
    "path": "lib/soundboard_web/gettext.ex",
    "chars": 833,
    "preview": "defmodule SoundboardWeb.Gettext do\n  @moduledoc \"\"\"\n  A module providing Internationalization with a gettext-based API.\n"
  },
  {
    "path": "lib/soundboard_web/live/favorites_live.ex",
    "chars": 2988,
    "preview": "defmodule SoundboardWeb.FavoritesLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n "
  },
  {
    "path": "lib/soundboard_web/live/favorites_live.html.heex",
    "chars": 5234,
    "preview": "<div class=\"max-w-6xl mx-auto px-4 py-8\">\n  <div class=\"flex justify-between items-center mb-8\">\n    <h1 class=\"text-3xl"
  },
  {
    "path": "lib/soundboard_web/live/settings_live.ex",
    "chars": 14505,
    "preview": "defmodule SoundboardWeb.SettingsLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  "
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live/edit_flow.ex",
    "chars": 6692,
    "preview": "defmodule SoundboardWeb.Live.SoundboardLive.EditFlow do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 3"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live/upload_flow.ex",
    "chars": 11063,
    "preview": "defmodule SoundboardWeb.Live.SoundboardLive.UploadFlow do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign:"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live.ex",
    "chars": 9805,
    "preview": "defmodule SoundboardWeb.SoundboardLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live.html.heex",
    "chars": 12629,
    "preview": "<div class=\"max-w-6xl mx-auto px-4 py-8\">\n  <div class=\"flex flex-wrap items-start justify-between gap-4 mb-8\">\n    <h1 "
  },
  {
    "path": "lib/soundboard_web/live/stats_live.ex",
    "chars": 18213,
    "preview": "defmodule SoundboardWeb.StatsLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  ali"
  },
  {
    "path": "lib/soundboard_web/live/support/flash_helpers.ex",
    "chars": 428,
    "preview": "defmodule SoundboardWeb.Live.Support.FlashHelpers do\n  @moduledoc false\n\n  import Phoenix.LiveView, only: [put_flash: 3]"
  },
  {
    "path": "lib/soundboard_web/live/support/live_tags.ex",
    "chars": 1395,
    "preview": "defmodule SoundboardWeb.Live.Support.LiveTags do\n  @moduledoc \"\"\"\n  LiveView-facing tag queries and mutations for the so"
  },
  {
    "path": "lib/soundboard_web/live/support/presence_live.ex",
    "chars": 2092,
    "preview": "defmodule SoundboardWeb.Live.Support.PresenceLive do\n  defmacro __using__(_opts) do\n    quote do\n      alias SoundboardW"
  },
  {
    "path": "lib/soundboard_web/live/support/sound_playback.ex",
    "chars": 620,
    "preview": "defmodule SoundboardWeb.Live.Support.SoundPlayback do\n  @moduledoc false\n\n  import Phoenix.LiveView, only: [put_flash: 3"
  },
  {
    "path": "lib/soundboard_web/live/support/tag_form.ex",
    "chars": 1584,
    "preview": "defmodule SoundboardWeb.Live.Support.TagForm do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 3]\n  impo"
  },
  {
    "path": "lib/soundboard_web/plugs/api_auth.ex",
    "chars": 1151,
    "preview": "defmodule SoundboardWeb.Plugs.APIAuth do\n  @moduledoc \"\"\"\n  API authentication plug.\n  \"\"\"\n  import Plug.Conn\n  alias So"
  },
  {
    "path": "lib/soundboard_web/plugs/basic_auth.ex",
    "chars": 1897,
    "preview": "defmodule SoundboardWeb.Plugs.BasicAuth do\n  @moduledoc \"\"\"\n  Basic authentication plug.\n\n  When both `BASIC_AUTH_USERNA"
  },
  {
    "path": "lib/soundboard_web/plugs/role_check.ex",
    "chars": 1260,
    "preview": "defmodule SoundboardWeb.Plugs.RoleCheck do\n  @moduledoc false\n  require Logger\n  import Plug.Conn\n  import Phoenix.Contr"
  },
  {
    "path": "lib/soundboard_web/presence.ex",
    "chars": 173,
    "preview": "defmodule SoundboardWeb.Presence do\n  @moduledoc \"\"\"\n  The Presence module.\n  \"\"\"\n  use Phoenix.Presence,\n    otp_app: :"
  },
  {
    "path": "lib/soundboard_web/presence_handler.ex",
    "chars": 3468,
    "preview": "defmodule SoundboardWeb.PresenceHandler do\n  @moduledoc \"\"\"\n  Handles presence tracking for the Soundboard app.\n  \"\"\"\n  "
  },
  {
    "path": "lib/soundboard_web/router.ex",
    "chars": 3048,
    "preview": "defmodule SoundboardWeb.Router do\n  use SoundboardWeb, :router\n\n  pipeline :browser do\n    plug :accepts, [\"html\"]\n    p"
  },
  {
    "path": "lib/soundboard_web/sound_helpers.ex",
    "chars": 597,
    "preview": "defmodule SoundboardWeb.SoundHelpers do\n  @moduledoc \"\"\"\n  Shared helpers for formatting sound metadata for UI rendering"
  },
  {
    "path": "lib/soundboard_web/soundboard/sound_filter.ex",
    "chars": 995,
    "preview": "defmodule SoundboardWeb.Soundboard.SoundFilter do\n  @moduledoc \"\"\"\n  Filters sounds based on the selected tags and searc"
  },
  {
    "path": "lib/soundboard_web/telemetry.ex",
    "chars": 2978,
    "preview": "defmodule SoundboardWeb.Telemetry do\n  use Supervisor\n  import Telemetry.Metrics\n\n  def start_link(arg) do\n    Superviso"
  },
  {
    "path": "lib/soundboard_web.ex",
    "chars": 2636,
    "preview": "defmodule SoundboardWeb do\n  @moduledoc \"\"\"\n  The entrypoint for defining your web interface, such\n  as controllers, com"
  },
  {
    "path": "mix.exs",
    "chars": 4617,
    "preview": "defmodule Soundboard.MixProject do\n  use Mix.Project\n\n  @moduledoc \"\"\"\n  Mix project configuration for Soundbored, the s"
  },
  {
    "path": "priv/gettext/en/LC_MESSAGES/errors.po",
    "chars": 2543,
    "preview": "## `msgid`s in this file come from POT (.pot) files.\n##\n## Do not add, change, or remove `msgid`s manually here as\n## th"
  },
  {
    "path": "priv/gettext/errors.pot",
    "chars": 2571,
    "preview": "## This is a PO Template file.\n##\n## `msgid`s here are often extracted from source code.\n## Add new translations manuall"
  },
  {
    "path": "priv/repo/migrations/.formatter.exs",
    "chars": 52,
    "preview": "[\n  import_deps: [:ecto_sql],\n  inputs: [\"*.exs\"]\n]\n"
  },
  {
    "path": "priv/repo/migrations/20250101213201_create_sounds.exs",
    "chars": 325,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateSounds do\n  use Ecto.Migration\n\n  def change do\n    create table(:sounds) do\n"
  },
  {
    "path": "priv/repo/migrations/20250101213717_create_tags.exs",
    "chars": 507,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateTags do\n  use Ecto.Migration\n\n  def change do\n    create table(:tags) do\n    "
  },
  {
    "path": "priv/repo/migrations/20250101231744_create_users.exs",
    "chars": 317,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateUsers do\n  use Ecto.Migration\n\n  def change do\n    create table(:users) do\n  "
  },
  {
    "path": "priv/repo/migrations/20250102212120_create_plays.exs",
    "chars": 354,
    "preview": "defmodule Soundboard.Repo.Migrations.CreatePlays do\n  use Ecto.Migration\n\n  def change do\n    create table(:plays) do\n  "
  },
  {
    "path": "priv/repo/migrations/20250102212121_create_favorites.exs",
    "chars": 342,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateFavorites do\n  use Ecto.Migration\n\n  def change do\n    create table(:favorite"
  },
  {
    "path": "priv/repo/migrations/20250102212122_add_user_id_to_sounds.exs",
    "chars": 205,
    "preview": "defmodule Soundboard.Repo.Migrations.AddUserIdToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds)"
  },
  {
    "path": "priv/repo/migrations/20250102212123_change_favorites_filename_to_sound_id.exs",
    "chars": 1952,
    "preview": "defmodule Soundboard.Repo.Migrations.ChangeFavoritesFilenameToSoundId do\n  use Ecto.Migration\n\n  def up do\n    # First a"
  },
  {
    "path": "priv/repo/migrations/20250102212124_add_index_to_plays.exs",
    "chars": 145,
    "preview": "defmodule Soundboard.Repo.Migrations.AddIndexToPlays do\n  use Ecto.Migration\n\n  def change do\n    create index(:plays, ["
  },
  {
    "path": "priv/repo/migrations/20250102212125_add_join_leave_flags_to_sounds.exs",
    "chars": 710,
    "preview": "defmodule Soundboard.Repo.Migrations.AddJoinLeaveFlagsToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table("
  },
  {
    "path": "priv/repo/migrations/20250102212126_add_url_to_sounds.exs",
    "chars": 226,
    "preview": "defmodule Soundboard.Repo.Migrations.AddUrlToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds) do"
  },
  {
    "path": "priv/repo/migrations/20250218214831_create_user_sound_settings.exs",
    "chars": 1354,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateUserSoundSettings do\n  use Ecto.Migration\n\n  def change do\n    create table(:"
  },
  {
    "path": "priv/repo/migrations/20250218214832_remove_join_leave_flags_from_sounds.exs",
    "chars": 379,
    "preview": "defmodule Soundboard.Repo.Migrations.RemoveJoinLeaveFlagsFromSounds do\n  use Ecto.Migration\n\n  def change do\n    drop_if"
  },
  {
    "path": "priv/repo/migrations/20250218220000_create_api_tokens.exs",
    "chars": 486,
    "preview": "defmodule Soundboard.Repo.Migrations.CreateApiTokens do\n  use Ecto.Migration\n\n  def change do\n    create table(:api_toke"
  },
  {
    "path": "priv/repo/migrations/20250218223000_add_token_plain_to_api_tokens.exs",
    "chars": 205,
    "preview": "defmodule Soundboard.Repo.Migrations.AddTokenPlainToApiTokens do\n  use Ecto.Migration\n\n  def change do\n    alter table(:"
  },
  {
    "path": "priv/repo/migrations/20250310120000_add_volume_to_sounds.exs",
    "chars": 195,
    "preview": "defmodule Soundboard.Repo.Migrations.AddVolumeToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds)"
  },
  {
    "path": "priv/repo/migrations/20260306150000_add_sound_id_to_plays.exs",
    "chars": 542,
    "preview": "defmodule Soundboard.Repo.Migrations.AddSoundIdToPlays do\n  use Ecto.Migration\n\n  def up do\n    alter table(:plays) do\n "
  },
  {
    "path": "priv/repo/migrations/20260306151000_finalize_favorites_and_sound_tags_migrations.exs",
    "chars": 2542,
    "preview": "defmodule Soundboard.Repo.Migrations.FinalizeFavoritesAndSoundTagsMigrations do\n  use Ecto.Migration\n\n  def up do\n    cr"
  },
  {
    "path": "priv/repo/migrations/20260307211000_rename_sound_name_to_played_filename_in_plays.exs",
    "chars": 644,
    "preview": "defmodule Soundboard.Repo.Migrations.RenameSoundNameToPlayedFilenameInPlays do\n  use Ecto.Migration\n\n  def up do\n    dro"
  },
  {
    "path": "priv/repo/seeds.exs",
    "chars": 98,
    "preview": "# Soundbored has no default seed data. Add only idempotent local bootstrap data here when needed.\n"
  },
  {
    "path": "priv/static/manifest.json",
    "chars": 431,
    "preview": "{\n  \"name\": \"SoundBored\",\n  \"short_name\": \"SoundBored\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"orientation\": "
  },
  {
    "path": "priv/static/robots.txt",
    "chars": 203,
    "preview": "# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file\n#\n# To ban all spider"
  },
  {
    "path": "test/soundboard/accounts/api_tokens_test.exs",
    "chars": 3772,
    "preview": "defmodule Soundboard.Accounts.ApiTokensTest do\n  use Soundboard.DataCase\n\n  import Mock\n\n  alias Soundboard.Accounts.{Ap"
  },
  {
    "path": "test/soundboard/accounts_test.exs",
    "chars": 1189,
    "preview": "defmodule Soundboard.AccountsTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts\n  alias Soundboard.Accounts."
  },
  {
    "path": "test/soundboard/audio_player/playback_engine_test.exs",
    "chars": 8060,
    "preview": "defmodule Soundboard.AudioPlayer.PlaybackEngineTest do\n  use ExUnit.Case, async: false\n\n  import ExUnit.CaptureLog\n  imp"
  },
  {
    "path": "test/soundboard/audio_player/playback_queue_test.exs",
    "chars": 13418,
    "preview": "defmodule Soundboard.AudioPlayer.PlaybackQueueTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard"
  },
  {
    "path": "test/soundboard/audio_player/sound_library_test.exs",
    "chars": 4391,
    "preview": "defmodule Soundboard.AudioPlayer.SoundLibraryTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts.User\n  alias"
  },
  {
    "path": "test/soundboard/discord/bot_identity_test.exs",
    "chars": 1385,
    "preview": "defmodule Soundboard.Discord.BotIdentityTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias EDA.API.User, as"
  },
  {
    "path": "test/soundboard/discord/handler/auto_join_policy_test.exs",
    "chars": 1812,
    "preview": "defmodule Soundboard.Discord.Handler.AutoJoinPolicyTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.H"
  },
  {
    "path": "test/soundboard/discord/handler/command_handler_test.exs",
    "chars": 1960,
    "preview": "defmodule Soundboard.Discord.Handler.CommandHandlerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Sound"
  },
  {
    "path": "test/soundboard/discord/handler/idle_timeout_policy_test.exs",
    "chars": 1480,
    "preview": "defmodule Soundboard.Discord.Handler.IdleTimeoutPolicyTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discor"
  },
  {
    "path": "test/soundboard/discord/handler/voice_presence_test.exs",
    "chars": 2216,
    "preview": "defmodule Soundboard.Discord.Handler.VoicePresenceTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundb"
  },
  {
    "path": "test/soundboard/discord/handler/voice_runtime_test.exs",
    "chars": 6638,
    "preview": "defmodule Soundboard.Discord.Handler.VoiceRuntimeTest do\n  use ExUnit.Case, async: false\n\n  import ExUnit.CaptureLog\n  i"
  },
  {
    "path": "test/soundboard/discord/role_checker_test.exs",
    "chars": 3278,
    "preview": "defmodule Soundboard.Discord.RoleCheckerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias EDA.API.Member\n "
  },
  {
    "path": "test/soundboard/discord/runtime_capability_test.exs",
    "chars": 960,
    "preview": "defmodule Soundboard.Discord.RuntimeCapabilityTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.Runtim"
  },
  {
    "path": "test/soundboard/discord/voice_test.exs",
    "chars": 1451,
    "preview": "defmodule Soundboard.Discord.VoiceTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.Voice\n\n  defmodule"
  },
  {
    "path": "test/soundboard/favorites_test.exs",
    "chars": 3169,
    "preview": "defmodule Soundboard.FavoritesTest do\n  @moduledoc \"\"\"\n  Test for the Favorites module.\n  \"\"\"\n  use Soundboard.DataCase\n"
  },
  {
    "path": "test/soundboard/migrations/data_migrations_test.exs",
    "chars": 7174,
    "preview": "for migration_file <- [\n      \"20250101213201_create_sounds.exs\",\n      \"20250101213717_create_tags.exs\",\n      \"2025010"
  },
  {
    "path": "test/soundboard/public_url_test.exs",
    "chars": 1000,
    "preview": "defmodule Soundboard.PublicURLTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.PublicURL\n\n  test \"current/0 r"
  },
  {
    "path": "test/soundboard/pubsub_topics_test.exs",
    "chars": 951,
    "preview": "defmodule Soundboard.PubSubTopicsTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.PubSubTopics\n\n  test \"expos"
  },
  {
    "path": "test/soundboard/sound_tag_test.exs",
    "chars": 2058,
    "preview": "defmodule Soundboard.SoundTagTest do\n  @moduledoc \"\"\"\n  Test for the SoundTag module.\n  \"\"\"\n  use Soundboard.DataCase\n  "
  },
  {
    "path": "test/soundboard/sound_test.exs",
    "chars": 16455,
    "preview": "defmodule Soundboard.SoundTest do\n  @moduledoc \"\"\"\n  Tests the Sound module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Sou"
  },
  {
    "path": "test/soundboard/sounds/management_test.exs",
    "chars": 4896,
    "preview": "defmodule Soundboard.Sounds.ManagementTest do\n  use Soundboard.DataCase\n\n  import Mock\n\n  alias Soundboard.Accounts.User"
  },
  {
    "path": "test/soundboard/sounds/sound_settings_test.exs",
    "chars": 5377,
    "preview": "defmodule Soundboard.Sounds.SoundSettingsTest do\n  @moduledoc \"\"\"\n  The SoundSettingsTest module.\n  \"\"\"\n  use Soundboard"
  },
  {
    "path": "test/soundboard/sounds/tags_test.exs",
    "chars": 3057,
    "preview": "defmodule Soundboard.Sounds.TagsTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{"
  },
  {
    "path": "test/soundboard/sounds/uploads_test.exs",
    "chars": 7045,
    "preview": "defmodule Soundboard.Sounds.UploadsTest do\n  use Soundboard.DataCase\n\n  import Soundboard.DataCase, only: [errors_on: 1]"
  },
  {
    "path": "test/soundboard/stats_test.exs",
    "chars": 5027,
    "preview": "defmodule Soundboard.StatsTest do\n  @moduledoc \"\"\"\n  Test for the Stats module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias "
  },
  {
    "path": "test/soundboard/tags/tag_test.exs",
    "chars": 2604,
    "preview": "defmodule Soundboard.Tags.TagTest do\n  @moduledoc \"\"\"\n  Tests the Tag module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias So"
  },
  {
    "path": "test/soundboard/uploads_path_test.exs",
    "chars": 1978,
    "preview": "defmodule Soundboard.UploadsPathTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.UploadsPath\n\n  setup do\n    "
  },
  {
    "path": "test/soundboard/volume_test.exs",
    "chars": 1392,
    "preview": "defmodule Soundboard.VolumeTest do\n  use ExUnit.Case, async: true\n  alias Soundboard.Volume\n\n  describe \"normalize_perce"
  },
  {
    "path": "test/soundboard_test.exs",
    "chars": 717,
    "preview": "defmodule SoundboardTest do\n  use ExUnit.Case, async: true\n  doctest Soundboard\n\n  describe \"module documentation\" do\n  "
  },
  {
    "path": "test/soundboard_web/audio_player_test.exs",
    "chars": 13719,
    "preview": "defmodule Soundboard.AudioPlayerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard.Accounts.User"
  },
  {
    "path": "test/soundboard_web/components/layouts/navbar_test.exs",
    "chars": 1663,
    "preview": "defmodule SoundboardWeb.Components.Layouts.NavbarTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveViewTest\n\n"
  },
  {
    "path": "test/soundboard_web/components/soundboard/edit_modal_test.exs",
    "chars": 1688,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.EditModalTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveView"
  },
  {
    "path": "test/soundboard_web/components/soundboard/upload_modal_test.exs",
    "chars": 1647,
    "preview": "defmodule SoundboardWeb.Components.Soundboard.UploadModalTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveVi"
  },
  {
    "path": "test/soundboard_web/controllers/api/sound_controller_test.exs",
    "chars": 11808,
    "preview": "defmodule SoundboardWeb.API.SoundControllerTest do\n  @moduledoc \"\"\"\n  Test for the SoundController.\n  \"\"\"\n  use Soundboa"
  },
  {
    "path": "test/soundboard_web/controllers/auth_controller_test.exs",
    "chars": 6564,
    "preview": "defmodule SoundboardWeb.AuthControllerTest do\n  use SoundboardWeb.ConnCase\n  alias Soundboard.{Accounts.User, Repo}\n  im"
  },
  {
    "path": "test/soundboard_web/controllers/upload_controller_test.exs",
    "chars": 1746,
    "preview": "defmodule SoundboardWeb.UploadControllerTest do\n  use SoundboardWeb.ConnCase\n\n  alias Soundboard.Accounts.User\n  alias S"
  },
  {
    "path": "test/soundboard_web/discord_handler_test.exs",
    "chars": 9142,
    "preview": "defmodule Soundboard.Discord.HandlerTest do\n  @moduledoc \"\"\"\n  Tests the DiscordHandler module.\n  \"\"\"\n  use Soundboard.D"
  },
  {
    "path": "test/soundboard_web/eda_consumer_test.exs",
    "chars": 708,
    "preview": "defmodule Soundboard.Discord.ConsumerTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.{Consumer, Hand"
  },
  {
    "path": "test/soundboard_web/live/favorites_live_test.exs",
    "chars": 3288,
    "preview": "defmodule SoundboardWeb.FavoritesLiveTest do\n  use SoundboardWeb.ConnCase\n\n  import Phoenix.LiveViewTest\n  import Mock\n\n"
  },
  {
    "path": "test/soundboard_web/live/settings_live_test.exs",
    "chars": 1835,
    "preview": "defmodule SoundboardWeb.SettingsLiveTest do\n  use SoundboardWeb.ConnCase\n  import Phoenix.LiveViewTest\n  alias Soundboar"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live/edit_flow_test.exs",
    "chars": 1351,
    "preview": "defmodule SoundboardWeb.Live.SoundboardLive.EditFlowTest do\n  use Soundboard.DataCase, async: true\n\n  alias Phoenix.Live"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live/upload_flow_test.exs",
    "chars": 2680,
    "preview": "defmodule SoundboardWeb.Live.SoundboardLive.UploadFlowTest do\n  use Soundboard.DataCase, async: true\n\n  alias Phoenix.Li"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live_test.exs",
    "chars": 12513,
    "preview": "defmodule SoundboardWeb.SoundboardLiveTest do\n  @moduledoc false\n  use SoundboardWeb.ConnCase\n  import Phoenix.LiveViewT"
  },
  {
    "path": "test/soundboard_web/live/stats_live_test.exs",
    "chars": 4347,
    "preview": "defmodule SoundboardWeb.StatsLiveTest do\n  @moduledoc \"\"\"\n  Test for the StatsLive component.\n  \"\"\"\n  use SoundboardWeb."
  },
  {
    "path": "test/soundboard_web/plugs/api_auth_db_token_test.exs",
    "chars": 2705,
    "preview": "defmodule SoundboardWeb.APIAuthDBTokenTest do\n  use SoundboardWeb.ConnCase\n  import Phoenix.ConnTest\n  import Mock\n\n  al"
  },
  {
    "path": "test/soundboard_web/plugs/basic_auth_test.exs",
    "chars": 3510,
    "preview": "defmodule SoundboardWeb.BasicAuthPlugTest do\n  use ExUnit.Case, async: true\n  import Plug.Test\n  import Plug.Conn\n\n  ali"
  },
  {
    "path": "test/soundboard_web/plugs/role_check_test.exs",
    "chars": 5743,
    "preview": "defmodule SoundboardWeb.Plugs.RoleCheckTest do\n  use SoundboardWeb.ConnCase, async: false\n\n  import Mock\n\n  alias Soundb"
  },
  {
    "path": "test/soundboard_web/presence_handler_test.exs",
    "chars": 3234,
    "preview": "defmodule SoundboardWeb.PresenceHandlerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias SoundboardWeb.Pre"
  },
  {
    "path": "test/soundboard_web/sound_helpers_test.exs",
    "chars": 843,
    "preview": "defmodule SoundboardWeb.SoundHelpersTest do\n  use ExUnit.Case, async: true\n  alias SoundboardWeb.SoundHelpers\n\n  describ"
  },
  {
    "path": "test/soundboard_web/soundboard/sound_filter_test.exs",
    "chars": 1120,
    "preview": "defmodule SoundboardWeb.Soundboard.SoundFilterTest do\n  use ExUnit.Case, async: true\n\n  alias SoundboardWeb.Soundboard.S"
  },
  {
    "path": "test/support/conn_case.ex",
    "chars": 1307,
    "preview": "defmodule SoundboardWeb.ConnCase do\n  @moduledoc false\n\n  use ExUnit.CaseTemplate\n  require Phoenix.LiveViewTest\n  @endp"
  },
  {
    "path": "test/support/data_case.ex",
    "chars": 1229,
    "preview": "defmodule Soundboard.DataCase do\n  @moduledoc false\n\n  use ExUnit.CaseTemplate\n  alias Ecto.Adapters.SQL.Sandbox\n\n  usin"
  },
  {
    "path": "test/support/test_helpers.ex",
    "chars": 2069,
    "preview": "defmodule Soundboard.TestHelpers do\n  @moduledoc \"\"\"\n  Helper functions for testing.\n  \"\"\"\n  alias Soundboard.{Accounts,"
  },
  {
    "path": "test/test_helper.exs",
    "chars": 118,
    "preview": "ExUnit.start()\n\nApplication.ensure_all_started(:soundboard)\n\nEcto.Adapters.SQL.Sandbox.mode(Soundboard.Repo, :manual)\n"
  }
]

About this extraction

This page contains the full source code of the christomitov/soundbored GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 199 files (628.2 KB), approximately 171.1k tokens, and a symbol index with 1000 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!