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/) Screenshot 2025-01-18 at 1 26 07 PM ### 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 `. 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 " ``` Returns `200 OK` with `%{data: [...]}`. #### Upload a local file ```bash curl -X POST https://soundboardurl.com/api/sounds \ -H "Authorization: Bearer " \ -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 " \ -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 " ``` 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 " ``` 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 ` 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 ` `; }, stopIcon() { return ` `; } } 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: // //
// 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 ` 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 ` | Extract audio from the YouTube URL and play it in the bot's current voice channel. | | `!play ` | 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: `` | | 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 " │ ▼ 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 `. - 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