[
  {
    "path": ".credo.exs",
    "content": "%{\n  configs: [\n    %{\n      name: \"default\",\n      files: %{\n        included: [\n          \"lib/\",\n          \"src/\",\n          \"test/\",\n          \"web/\",\n          \"apps/*/lib/\",\n          \"apps/*/src/\",\n          \"apps/*/test/\",\n          \"apps/*/web/\"\n        ],\n        excluded: [~r\"/_build/\", ~r\"/deps/\", ~r\"/node_modules/\"]\n      },\n      plugins: [],\n      requires: [],\n      strict: false,\n      parse_timeout: 5000,\n      color: true,\n      checks: %{\n        enabled: [\n          {Credo.Check.Consistency.ExceptionNames, []},\n          {Credo.Check.Consistency.LineEndings, []},\n          {Credo.Check.Consistency.ParameterPatternMatching, []},\n          {Credo.Check.Consistency.SpaceAroundOperators, []},\n          {Credo.Check.Consistency.SpaceInParentheses, []},\n          {Credo.Check.Consistency.TabsOrSpaces, []},\n          {Credo.Check.Design.AliasUsage,\n           [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},\n          {Credo.Check.Design.TagFIXME, []},\n          {Credo.Check.Design.TagTODO, [exit_status: 2]},\n          {Credo.Check.Readability.AliasOrder, []},\n          {Credo.Check.Readability.FunctionNames, []},\n          {Credo.Check.Readability.LargeNumbers, []},\n          {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},\n          {Credo.Check.Readability.ModuleAttributeNames, []},\n          {Credo.Check.Readability.ModuleDoc, []},\n          {Credo.Check.Readability.ModuleNames, []},\n          {Credo.Check.Readability.ParenthesesInCondition, []},\n          {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},\n          {Credo.Check.Readability.PipeIntoAnonymousFunctions, []},\n          {Credo.Check.Readability.PredicateFunctionNames, []},\n          {Credo.Check.Readability.PreferImplicitTry, []},\n          {Credo.Check.Readability.RedundantBlankLines, []},\n          {Credo.Check.Readability.Semicolons, []},\n          {Credo.Check.Readability.SpaceAfterCommas, []},\n          {Credo.Check.Readability.StringSigils, []},\n          {Credo.Check.Readability.TrailingBlankLine, []},\n          {Credo.Check.Readability.TrailingWhiteSpace, []},\n          {Credo.Check.Readability.UnnecessaryAliasExpansion, []},\n          {Credo.Check.Readability.VariableNames, []},\n          {Credo.Check.Readability.WithSingleClause, []},\n          {Credo.Check.Refactor.Apply, []},\n          {Credo.Check.Refactor.CondStatements, []},\n          {Credo.Check.Refactor.CyclomaticComplexity, []},\n          {Credo.Check.Refactor.FilterCount, []},\n          {Credo.Check.Refactor.FilterFilter, []},\n          {Credo.Check.Refactor.FunctionArity, []},\n          {Credo.Check.Refactor.LongQuoteBlocks, []},\n          {Credo.Check.Refactor.MapJoin, []},\n          {Credo.Check.Refactor.MatchInCondition, []},\n          {Credo.Check.Refactor.NegatedConditionsInUnless, []},\n          {Credo.Check.Refactor.NegatedConditionsWithElse, []},\n          {Credo.Check.Refactor.Nesting, []},\n          {Credo.Check.Refactor.RedundantWithClauseResult, []},\n          {Credo.Check.Refactor.RejectReject, []},\n          {Credo.Check.Refactor.UnlessWithElse, []},\n          {Credo.Check.Refactor.WithClauses, []},\n          {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},\n          {Credo.Check.Warning.BoolOperationOnSameValues, []},\n          {Credo.Check.Warning.Dbg, []},\n          {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},\n          {Credo.Check.Warning.IExPry, []},\n          {Credo.Check.Warning.IoInspect, []},\n          {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},\n          {Credo.Check.Warning.OperationOnSameValues, []},\n          {Credo.Check.Warning.OperationWithConstantResult, []},\n          {Credo.Check.Warning.RaiseInsideRescue, []},\n          {Credo.Check.Warning.SpecWithStruct, []},\n          {Credo.Check.Warning.UnsafeExec, []},\n          {Credo.Check.Warning.UnusedEnumOperation, []},\n          {Credo.Check.Warning.UnusedFileOperation, []},\n          {Credo.Check.Warning.UnusedKeywordOperation, []},\n          {Credo.Check.Warning.UnusedListOperation, []},\n          {Credo.Check.Warning.UnusedPathOperation, []},\n          {Credo.Check.Warning.UnusedRegexOperation, []},\n          {Credo.Check.Warning.UnusedStringOperation, []},\n          {Credo.Check.Warning.UnusedTupleOperation, []},\n          {Credo.Check.Warning.WrongTestFileExtension, []},\n\n          # ExSlop Warning checks\n          {ExSlop.Check.Warning.BlanketRescue, []},\n          {ExSlop.Check.Warning.RescueWithoutReraise, []},\n          {ExSlop.Check.Warning.RepoAllThenFilter, []},\n          {ExSlop.Check.Warning.QueryInEnumMap, []},\n          {ExSlop.Check.Warning.GenserverAsKvStore, []},\n\n          # ExSlop Refactor checks\n          {ExSlop.Check.Refactor.FilterNil, []},\n          {ExSlop.Check.Refactor.RejectNil, []},\n          {ExSlop.Check.Refactor.ReduceAsMap, []},\n          {ExSlop.Check.Refactor.MapIntoLiteral, []},\n          {ExSlop.Check.Refactor.IdentityPassthrough, []},\n          {ExSlop.Check.Refactor.IdentityMap, []},\n          {ExSlop.Check.Refactor.CaseTrueFalse, []},\n          {ExSlop.Check.Refactor.TryRescueWithSafeAlternative, []},\n          {ExSlop.Check.Refactor.WithIdentityElse, []},\n          {ExSlop.Check.Refactor.WithIdentityDo, []},\n          {ExSlop.Check.Refactor.SortThenReverse, []},\n          {ExSlop.Check.Refactor.StringConcatInReduce, []},\n\n          # ExSlop Readability checks\n          {ExSlop.Check.Readability.NarratorDoc, []},\n          {ExSlop.Check.Readability.DocFalseOnPublicFunction, []},\n          {ExSlop.Check.Readability.BoilerplateDocParams, []},\n          {ExSlop.Check.Readability.ObviousComment, []},\n          {ExSlop.Check.Readability.StepComment, []},\n          {ExSlop.Check.Readability.NarratorComment, []}\n        ],\n        disabled: [\n          {Credo.Check.Refactor.UtcNowTruncate, []},\n          {Credo.Check.Consistency.MultiAliasImportRequireUse, []},\n          {Credo.Check.Consistency.UnusedVariableNames, []},\n          {Credo.Check.Design.DuplicatedCode, []},\n          {Credo.Check.Design.SkipTestWithoutComment, []},\n          {Credo.Check.Readability.AliasAs, []},\n          {Credo.Check.Readability.BlockPipe, []},\n          {Credo.Check.Readability.ImplTrue, []},\n          {Credo.Check.Readability.MultiAlias, []},\n          {Credo.Check.Readability.NestedFunctionCalls, []},\n          {Credo.Check.Readability.OneArityFunctionInPipe, []},\n          {Credo.Check.Readability.OnePipePerLine, []},\n          {Credo.Check.Readability.SeparateAliasRequire, []},\n          {Credo.Check.Readability.SingleFunctionToBlockPipe, []},\n          {Credo.Check.Readability.SinglePipe, []},\n          {Credo.Check.Readability.Specs, []},\n          {Credo.Check.Readability.StrictModuleLayout, []},\n          {Credo.Check.Readability.WithCustomTaggedTuple, []},\n          {Credo.Check.Refactor.ABCSize, []},\n          {Credo.Check.Refactor.AppendSingleItem, []},\n          {Credo.Check.Refactor.DoubleBooleanNegation, []},\n          {Credo.Check.Refactor.FilterReject, []},\n          {Credo.Check.Refactor.IoPuts, []},\n          {Credo.Check.Refactor.MapMap, []},\n          {Credo.Check.Refactor.ModuleDependencies, []},\n          {Credo.Check.Refactor.NegatedIsNil, []},\n          {Credo.Check.Refactor.PassAsyncInTestCases, []},\n          {Credo.Check.Refactor.PipeChainStart, []},\n          {Credo.Check.Refactor.RejectFilter, []},\n          {Credo.Check.Refactor.VariableRebinding, []},\n          {Credo.Check.Warning.LazyLogging, []},\n          {Credo.Check.Warning.LeakyEnvironment, []},\n          {Credo.Check.Warning.MapGetUnsafePass, []},\n          {Credo.Check.Warning.MixEnv, []},\n          {Credo.Check.Warning.UnsafeToAtom, []}\n        ]\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": ".dockerignore",
    "content": ".dockerignore\n.git*\ntest\n.env*\n*.md\ncoveralls.json\ndocker-compose.yml\nDockerfile\n"
  },
  {
    "path": ".ex_dna.exs",
    "content": "%{\n  min_mass: 25,\n  ignore: [\"lib/soundboard_web/templates/**\"],\n  excluded_macros: [:@, :schema, :pipe_through, :plug],\n  normalize_pipes: true\n}\n"
  },
  {
    "path": ".formatter.exs",
    "content": "[\n  import_deps: [:ecto, :ecto_sql, :phoenix],\n  subdirectories: [\"priv/*/migrations\"],\n  plugins: [Phoenix.LiveView.HTMLFormatter],\n  inputs: [\"*.{heex,ex,exs}\", \"{config,lib,test}/**/*.{heex,ex,exs}\", \"priv/*/seeds.exs\"]\n]\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"mix\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    open-pull-requests-limit: 1\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI/CD Pipeline\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\njobs:\n  test:\n    name: Build and test\n    runs-on: ubuntu-latest\n    environment: Builder\n\n    steps:\n      - uses: actions/checkout@v5\n      \n      - name: Set up Elixir\n        uses: erlef/setup-beam@v1\n        with:\n          elixir-version: '1.19.x'\n          otp-version: '27.x'\n          \n      - name: Restore dependencies cache\n        uses: actions/cache@v4\n        with:\n          path: deps\n          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}\n          restore-keys: ${{ runner.os }}-mix-\n          \n      - name: Install dependencies\n        run: mix deps.get\n        \n      - name: Compile (warnings as errors)\n        run: mix compile --warnings-as-errors\n        \n      - name: Install SQLite dependencies\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y sqlite3 libsqlite3-dev\n          \n      - name: Run tests with coverage\n        run: mix coveralls.github\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}\n          MIX_ENV: test\n          DISCORD_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}\n          \n      - name: Verify minimum coverage\n        run: |\n          COVERAGE=$(mix coveralls | grep \"\\[TOTAL\\]\" | awk '{print $2}' | sed 's/%//')\n          MINIMUM=80.0\n          \n          if (( $(echo \"$COVERAGE < $MINIMUM\" | bc -l) )); then\n            echo \"Test coverage is below minimum: $COVERAGE% < $MINIMUM%\"\n            exit 1\n          else\n            echo \"Coverage is $COVERAGE%, which meets the minimum requirement of $MINIMUM%\"\n          fi\n        env:\n          DISCORD_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}\n          \n      - name: Run Credo\n        run: mix credo --strict\n        \n\n  build-and-push:\n    needs: test\n    runs-on: ubuntu-latest\n    environment: Builder\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v5\n        \n      - name: Set up QEMU (for multi-platform builds)\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        \n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: |\n            ${{ secrets.DOCKERHUB_USERNAME }}/soundbored:latest\n            ${{ secrets.DOCKERHUB_USERNAME }}/soundbored:${{ github.sha }}\n            ghcr.io/${{ github.repository }}/soundbored:latest\n            ghcr.io/${{ github.repository }}/soundbored:${{ github.sha }}\n\n  docs:\n    needs: test\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n    permissions:\n      contents: write\n\n    steps:\n      - uses: actions/checkout@v5\n        with:\n          fetch-depth: 0\n\n      - name: Set up Elixir\n        uses: erlef/setup-beam@v1\n        with:\n          elixir-version: '1.19.x'\n          otp-version: '27.x'\n\n      - name: Restore dependencies cache\n        uses: actions/cache@v4\n        with:\n          path: deps\n          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}\n          restore-keys: ${{ runner.os }}-mix-\n\n      - name: Install dependencies\n        run: mix deps.get\n\n      - name: Generate documentation\n        run: mix docs\n\n      - name: Deploy documentation to GitHub Pages\n        uses: peaceiris/actions-gh-pages@v3\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          publish_branch: gh-pages\n          publish_dir: doc\n          enable_jekyll: false\n"
  },
  {
    "path": ".gitignore",
    "content": "# The directory Mix will write compiled artifacts to.\n/_build/\n\n/reports/\n\n# If you run \"mix test --cover\", coverage assets end up here.\n/cover/\n\n# The directory Mix downloads your dependencies sources to.\n/deps/\n\n# Where 3rd-party dependencies like ExDoc output generated docs.\n/doc/\n\n# Ignore .fetch files in case you like to edit your project deps locally.\n/.fetch\n\n# If the VM crashes, it generates a dump, let's ignore it too.\nerl_crash.dump\n\n# Also ignore archive artifacts (built via \"mix archive.build\").\n*.ez\n\n# Temporary files, for example, from tests.\n/tmp/\n\n# Ignore package tarball (built via \"mix hex.build\").\nsoundboard-*.tar\n\n# Ignore assets that are produced by build tools.\n/priv/static/assets/\n\n# Ignore digested assets cache.\n/priv/static/cache_manifest.json\n/priv/static/favicon-*.ico\n/priv/static/manifest-*.json\n/priv/static/manifest-*.json.gz\n/priv/static/manifest.json.gz\n/priv/static/robots-*.txt\n/priv/static/robots-*.txt.gz\n/priv/static/robots.txt.gz\n/priv/static/images/icon-*\n/priv/static/images/*-*\n\n# In case you use Node.js/npm, you want to ignore these.\nnpm-debug.log\n/assets/node_modules/\n\n/priv/static/uploads/\n\n.env\n\n.DS_Store\n\n\nCLAUDE.md\n\n.serena\n\nTODO\n\n.desloppify/\n.pi/\n\n.env.local\n"
  },
  {
    "path": ".tool-versions",
    "content": "erlang 27.2\nelixir 1.19.0-otp-27\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Repository Guidelines\n\n## Project Structure & Module Organization\n- Source: `lib/soundboard` (core), `lib/soundboard_web` (Phoenix web, LiveView, controllers, components).\n- Frontend assets: `assets/js`, `assets/css`, `assets/tailwind.config.js`.\n- Config and priv: `config/`, `priv/` (static, migrations, etc.).\n- Tests: `test/` mirroring lib; helpers in `test/support`.\n\n## Build, Test, and Development Commands\n- Setup: `mix setup` — fetch deps, create/migrate DB, install/build assets.\n- Run dev server: `mix phx.server` (or `iex -S mix phx.server`).\n- Tests: `mix test` — includes DB setup via alias.\n- Coverage: `mix coveralls` or `mix coveralls.html` (outputs to `cover/`).\n- Lint/format: `mix credo --strict` and `mix format`.\n- Assets prod build: `mix assets.deploy`.\n- Docker local: `docker compose up` (env from `.env`).\n\n## Coding Style & Naming Conventions\n- Elixir style: 2‑space indent; run `mix format` before committing.\n- Modules: `Soundboard.*` and `SoundboardWeb.*`; filenames snake_case.\n- Functions/vars: snake_case; constants via module attributes.\n- Components/LiveViews live under `lib/soundboard_web/{components,live}` with descriptive names (e.g., `favorites_live.ex`).\n\n## Testing Guidelines\n- Framework: ExUnit with helpers in `test/support` (`ConnCase`, `DataCase`).\n- Naming: mirror module under `test/…/*_test.exs` (e.g., `stats_test.exs`).\n- Run selective: `mix test test/soundboard/stats_test.exs:42`.\n- Coverage: aim >90% for new code; add unit tests for contexts and LiveView interaction where feasible.\n\n## Commit & Pull Request Guidelines\n- Commits: imperative mood, concise (e.g., \"Fix audio playback path\"). Group related changes.\n- PRs: clear description, linked issues, screenshots for UI changes, reproduction steps, and risk/rollback notes.\n- Checks: run `mix precommit` before pushing; it covers compile warnings, unused deps, formatting, Credo, tests, and clone detection.\n\n## Security & Configuration Tips\n- Secrets via `.env` (see `.env.example`): Discord tokens, API token, `PHX_HOST`, `SCHEME`.\n- Do not commit real secrets; prefer Docker env files in development and deployment.\n- For production, keep secrets in `.env` and run the single compose stack; integrate your own reverse proxy/load balancer as needed.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nFROM elixir:1.19-alpine AS build\n\nARG MIX_ENV=prod\n\nENV MIX_ENV=$MIX_ENV \\\n    MIX_HOME=/app/.mix \\\n    HEX_HOME=/app/.hex \\\n    LANG=C.UTF-8 \\\n    LC_ALL=C.UTF-8 \\\n    LC_CTYPE=C.UTF-8\n\nRUN apk add --no-cache \\\n    git \\\n    make \\\n    build-base \\\n    rust \\\n    cargo\n\nWORKDIR /app\nCOPY --exclude=entrypoint.sh . .\n\n# Install hex/rebar, get dependencies, and refresh EDA from latest main.\nRUN mkdir -p /app/.mix /app/.hex && \\\n    mix local.hex --force && \\\n    mix local.rebar --force && \\\n    mix deps.get && \\\n    mix deps.update eda\n\nRUN export SKIP_RUNTIME_CONFIG=1 && \\\n    mix assets.setup && \\\n    mix compile && \\\n    mix assets.deploy && \\\n    cd deps/eda/native/eda_dave && \\\n    cargo build --release && \\\n    mkdir -p /app/_build/${MIX_ENV}/lib/eda/priv/native && \\\n    cp target/release/libeda_dave.so /app/_build/${MIX_ENV}/lib/eda/priv/native/eda_dave.so\n\nFROM elixir:1.19-alpine\n\nENV MIX_ENV=prod \\\n    MIX_HOME=/app/.mix \\\n    HEX_HOME=/app/.hex \\\n    HOME=/app \\\n    LANG=C.UTF-8 \\\n    LC_ALL=C.UTF-8 \\\n    LC_CTYPE=C.UTF-8\n\nRUN apk add --no-cache \\\n    ffmpeg \\\n    git \\\n    libstdc++\n\nWORKDIR /app\nCOPY --from=build /app .\nRUN chmod -R a+rX /app/.mix /app/.hex\n\nCOPY entrypoint.sh /app\nRUN chmod a+x /app/entrypoint.sh\n\nVOLUME [\"/app/priv/static/uploads\"]\nEXPOSE 4000\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "README.md",
    "content": "# Soundbored\n[![Coverage Status](https://coveralls.io/repos/github/christomitov/soundbored/badge.svg?branch=main)](https://coveralls.io/github/christomitov/soundbored?branch=main)\n[![Build Status](https://github.com/christomitov/soundbored/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/christomitov/soundbored/actions)\n\nSoundbored is an unlimited, no-cost, self-hosted soundboard for Discord. It allows you to play sounds in a voice channel.\n\n\n[Hexdocs](https://christomitov.github.io/soundbored/)\n\n<img width=\"1468\" alt=\"Screenshot 2025-01-18 at 1 26 07 PM\" src=\"https://github.com/user-attachments/assets/4a504100-5ef9-47bc-b406-35b67837e116\" />\n\n### CLI Companion\nInstall the cross-platform CLI with `npm i -g soundbored` for quick automation. Source: [christomitov/soundbored-cli](https://github.com/christomitov/soundbored-cli).\n\n## Quickstart\n\n1. Copy the sample environment and set the minimum values:\n   ```bash\n   cp .env.example .env\n   # Required for local testing\n   # DISCORD_TOKEN=...\n   # DISCORD_CLIENT_ID=...\n   # DISCORD_CLIENT_SECRET=...\n   # PHX_HOST=localhost\n   # SCHEME=http\n   ```\n2. Run the published container:\n   ```bash\n   docker run -d -p 4000:4000 --env-file ./.env christom/soundbored\n   ```\n3. Visit http://localhost:4000, invite the bot, and trigger your first sound.\n\n> 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.\n\n### Discord App Setup\n\n1. In the Discord Developer Portal, open your application and go to **Bot** → enable **Presence**, **Server Members**, and **Message Content** intents.\n2. Still in the portal, go to **OAuth2 → Redirects** and add every URL that will serve Soundbored to the **Redirects** list. For example:\n   - `http://localhost:4000/auth/discord/callback` (local development)\n   - `https://your.domain.com/auth/discord/callback` (production, replace with your domain)\n   Discord requires the redirect in your app configuration to match exactly what the browser uses during login; otherwise, OAuth will fail.\n3. 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`.\n4. Use **OAuth2 → URL Generator** (scope `bot`) to produce the invite link with the permissions listed above.\n\n## Local Development\n\n```bash\nmix setup        # Fetch deps, prepare DB, build assets\nmix phx.server   # or iex -S mix phx.server\n```\n\nUseful commands:\n- `mix test` – run the test suite (coverage via `mix coveralls`).\n- `mix credo --strict` – linting.\n\n`docker compose up` also works for a containerized local run; it respects the same `.env` configuration.\n\n## Environment Variables\n\nAll available keys live in `.env.example`. Configure the ones that match your setup:\n\n| Variable | Required | Purpose |\n| --- | --- | --- |\n| `DISCORD_TOKEN` | ✔ | Bot token used to play audio in voice channels. |\n| `EDA_DAVE` | optional | Override for Discord E2EE voice negotiation in EDA. Default is enabled; set `false` only for troubleshooting. |\n| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | ✔ | OAuth credentials for Discord login. |\n| `BASIC_AUTH_USERNAME` / `BASIC_AUTH_PASSWORD` | optional | Protect the browser UI with HTTP basic auth. API routes stay behind API token auth. |\n| `SECRET_KEY_BASE` | ✔ | Signing/encryption secret; generate via `mix phx.gen.secret` or `openssl rand -base64 48`. Takes precedence over `SECRET_KEY_BASE_FILE`.|\n| `SECRET_KEY_BASE_FILE` | optional | Path to file containing signing/encryption secret (e.g. for docker secrets). Preferred for security. |\n| `PHX_HOST` | ✔ | Hostname the app advertises (`localhost` for local runs). |\n| `SCHEME` | ✔ | `http` locally, `https` in production. |\n| `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. |\n| `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. |\n| `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). |\n\n## Deployment\n\nThe application is published to Docker Hub as `christom/soundbored`.\n\n### Simple Docker Host\n```bash\ndocker pull christom/soundbored:latest\ndocker run -d -p 4000:4000 --env-file ./.env christom/soundbored\n```\nIf 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.\n\n## Usage\n\nAfter 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.\n\nThe bot manages voice channels automatically in two ways:\n\nThe `AUTO_JOIN` variable controls three modes:\n\n- **`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).\n- **`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.\n- **`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.\n\n## API\n\nThe API is used to trigger sounds from other applications. Create a personal API token in **Settings** after signing in, then send it as `Authorization: Bearer <USER_API_TOKEN>`.\n\nCurrent API workflow supports:\n- listing sounds\n- uploading local files\n- creating URL-backed sounds\n- queueing playback for a specific sound\n- stopping active playback\n\n### Endpoints\n\n#### List sounds\n```bash\ncurl https://soundboardurl.com/api/sounds \\\n  -H \"Authorization: Bearer <USER_API_TOKEN>\"\n```\nReturns `200 OK` with `%{data: [...]}`.\n\n#### Upload a local file\n```bash\ncurl -X POST https://soundboardurl.com/api/sounds \\\n  -H \"Authorization: Bearer <USER_API_TOKEN>\" \\\n  -F \"source_type=local\" \\\n  -F \"name=wow\" \\\n  -F \"file=@/path/to/wow.mp3\" \\\n  -F \"tags[]=meme\" \\\n  -F \"volume=90\"\n```\nReturns `201 Created` with `%{data: sound}`.\n\n#### Create a URL-backed sound\n```bash\ncurl -X POST https://soundboardurl.com/api/sounds \\\n  -H \"Authorization: Bearer <USER_API_TOKEN>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"source_type\":\"url\",\"name\":\"wow\",\"url\":\"https://example.com/wow.mp3\",\"tags\":[\"meme\",\"reaction\"],\"volume\":90}'\n```\nReturns `201 Created` with `%{data: sound}`.\n\n#### Queue playback for a sound\n```bash\ncurl -X POST https://soundboardurl.com/api/sounds/123/play \\\n  -H \"Authorization: Bearer <USER_API_TOKEN>\"\n```\nReturns `202 Accepted` with `%{data: %{status: \"accepted\", ...}}` because playback is queued asynchronously.\n\n#### Stop active playback\n```bash\ncurl -X POST https://soundboardurl.com/api/sounds/stop \\\n  -H \"Authorization: Bearer <USER_API_TOKEN>\"\n```\nReturns `202 Accepted` with `%{data: %{status: \"accepted\", ...}}` because the stop request is also asynchronous.\n\nErrors use `%{error: message}` or `%{errors: changeset_errors}` depending on whether the failure is request-level or validation-level.\n\n## Changelog\n\n### v1.7.0 (2026-03-07)\n\n#### ✨ New Features\n- Switched the Discord voice/runtime integration over to EDA, bringing DAVE support for current Discord voice encryption negotiation.\n- 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.\n- Public URL handling is now centralized so Discord invite/auth links and API examples stay aligned with the configured host and scheme.\n\n#### ⚙️ Improvements\n- Audio playback startup is faster and more resilient, reducing common delay/glitch cases during sound playback.\n- Voice runtime handling was split into smaller policy/command/presence modules, making Discord connection behavior easier to reason about and maintain.\n- Upload and tag persistence flows were consolidated so the LiveView and API paths share the same domain logic.\n- The app now boots in a degraded mode when optional voice runtime capabilities are unavailable instead of failing startup entirely.\n\n#### 🧪 Tests & Quality\n- Added coverage for command handling, runtime capability detection, public URL behavior, API auth, upload flows, and collaborative sound management rules.\n- Clarified the intended collaboration model: any signed-in user can edit shared sound details, but only the original uploader can delete a sound.\n- Removed stale dependencies and cleanup scaffolding while continuing the broader code-health refactor.\n\n### v1.6.0 (2025-10-01)\n\n#### ✨ New Features\n- New consolidated `Settings` view replaces the standalone API tokens screen and keeps token creation, revocation, and inline API examples in one place.\n- Stats dashboard adds a week picker, richer recent activity stream, and refreshed layout under the new name “Stats”.\n- “Play Random” now respects whatever filters are active, pulling from the current search results or selected tags only.\n\n#### ⚙️ Improvements\n- Shared tag components and modal tweaks streamline sound management and reduce layout shifts.\n- Navigation highlights the active page and keeps Settings aligned with the rest of the app.\n- Mobile refinements across the main board and settings eliminate horizontal scrolling and polish button spacing.\n- Basic Auth now quietly skips enforcement when credentials are not configured instead of blocking the UI.\n\n#### 🧪 Tests & Quality\n- Expanded LiveView coverage for the new Settings page, Stats interactions, and filtered random playback.\n- Updated CI workflow and Dependabot configuration keep coverage and dependency checks automated.\n\n#### 📦 Dependencies\n- Bumped Phoenix stack and related dependencies, plus cleaned up mix configuration and docs to match the new release.\n\n### v1.5.0 (2025-09-14)\n\n#### ✨ New Features\n- User-scoped API tokens with DB storage (generate/revoke in Settings > API Tokens).\n- API requests authenticated via `Authorization: Bearer <token>` are attributed to the token’s user and increment stats accordingly.\n- In-app API help with copy-to-clipboard curl commands that auto-fill your site URL and token.\n- Added Settings link in the navbar for quick access.\n- Released a new CLI for easier local and CI integrations.\n\n#### ⚙️ Improvements\n- Search bar: reduced debounce to 200ms and added inline spinner while searching.\n- Recent Plays: fixed item “disappearing” by using stable DB ids and deterministic ordering; clicked items now bump to the top correctly.\n\n#### 🧪 Tests & Quality\n- Added tests for API token lifecycle, API auth with DB tokens, Basic Auth, and the Settings LiveView.\n- Coverage improved to ~96% (via mix coveralls).\n\n#### 🔁 Compatibility\n- DB-backed personal API tokens are the supported authentication path for API access.\n\n### v1.4.0 (2025-08-22)\n\n#### 🐛 Bug Fixes\n- Fixed sounds not playing due to Discord API changes\n- Optimized audio playback for faster sound loading and playback\n\n#### 🔧 Maintenance\n- Updated all dependencies to latest versions\n\n### v1.3.0 (2025-02-18)\n\n#### ✨ New Features\n- Added API to get and trigger sounds.\n- Added \"stop all sounds\" button.\n- Implemented auto leave and join voice channels.\n- Sorting sounds alphabetically\n- Added ability to disable basic auth (just comment out BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD in .env)\n\n### v1.2.0 (2025-01-18)\n\n#### ✨ New Features\n- Added random sound button.\n- Added ability to add and trigger sounds from a URL.\n- Allow ability to click tags inside sound Cards for filtering.\n- Show what user uploaded a sound in the sound Card.\n\n#### 🐛 Bug Fixes\n- Fixed bug where if you uploaded a sound and edited its name before uploading a file it would crash.\n- Fixed bug where changing an uploaded sound name created a new sound in entry and didn't update the old.\n\n### v1.1.0 (2025-01-12)\n\n#### ✨ New Features\n- Implemented join/leave sound notifications\n- Added Discord avatar support for member profiles\n- Added week selector functionality to statistics page\n\n#### 🐛 Bug Fixes\n- Fixed mobile menu navigation issues on statistics page\n- Fixed statistics page not updating in realtime\n- Fixed styling issues on stats page\n"
  },
  {
    "path": "assets/css/app.css",
    "content": "@import \"tailwindcss/base\";\n@import \"tailwindcss/components\";\n@import \"tailwindcss/utilities\";\n\n.flash-message {\n  opacity: 0;\n  transition: opacity 300ms ease-in-out;\n}\n\n.opacity-0 { opacity: 0; }\n.opacity-100 { opacity: 1; }\n\n:root {\n  background-color: rgb(17 24 39);\n  height: 100%;\n}\n\nhtml {\n  @apply bg-gray-900;\n  height: 100%;\n  /* Hide scrollbar for Chrome, Safari and Opera */\n  ::-webkit-scrollbar {\n    display: none;\n  }\n  /* Hide scrollbar for IE, Edge and Firefox */\n  -ms-overflow-style: none;  /* IE and Edge */\n  scrollbar-width: none;  /* Firefox */\n}\n\nbody {\n  @apply bg-gray-900;\n  min-height: 100%;\n  padding-top: env(safe-area-inset-top);\n  padding-bottom: env(safe-area-inset-bottom);\n  padding-left: env(safe-area-inset-left);\n  padding-right: env(safe-area-inset-right);\n  /* Enables momentum scrolling on iOS */\n  -webkit-overflow-scrolling: touch;\n  /* Hide scrollbar while allowing scrolling */\n  overflow-y: auto;\n  ::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n.loading-container {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  min-height: 200px;\n}\n\n.loading-spinner {\n  width: 40px;\n  height: 40px;\n  border: 3px solid #f3f3f3;\n  border-top: 3px solid #3498db;\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n  margin-bottom: 1rem;\n}\n\n@keyframes spin {\n  0% { transform: rotate(0deg); }\n  100% { transform: rotate(360deg); }\n}\n\n/* Show inline search spinner while server processes phx-change */\n.phx-change-loading .search-spinner { display: inline; }\n.phx-change-loading .search-icon { display: none; }\n"
  },
  {
    "path": "assets/js/app.js",
    "content": "// If you want to use Phoenix channels, run `mix help phx.gen.channel`\n// to get started and then uncomment the line below.\n// import \"./user_socket.js\"\n\n// You can include dependencies in two ways.\n//\n// The simplest option is to put them in assets/vendor and\n// import them using relative paths:\n//\n//     import \"../vendor/some-package.js\"\n//\n// Alternatively, you can `npm install some-package --prefix assets` and import\n// them using a path starting with the package name:\n//\n//     import \"some-package\"\n//\n\n// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.\nimport \"phoenix_html\"\n// Establish Phoenix Socket and LiveView configuration.\nimport {Socket} from \"phoenix\"\nimport {LiveSocket} from \"phoenix_live_view\"\nimport topbar from \"../vendor/topbar\"\n\nlet csrfToken = document.querySelector(\"meta[name='csrf-token']\").getAttribute(\"content\")\n\nconst clamp = (value, min, max) => Math.min(Math.max(value, min), max)\nconst roundTo = (value, decimals = 4) => {\n  const factor = Math.pow(10, decimals)\n  return Math.round(value * factor) / factor\n}\n\nconst MAX_VOLUME_PERCENT_DEFAULT = 150\nconst BOOST_CAP = 1.5\n\nconst getAudioContextCtor = () => window.AudioContext || window.webkitAudioContext || null\n\nconst parsePercent = (\n  value,\n  fallback = MAX_VOLUME_PERCENT_DEFAULT,\n  maxPercent = MAX_VOLUME_PERCENT_DEFAULT\n) => {\n  const parseNumeric = (input) => {\n    if (typeof input === \"number\" && Number.isFinite(input)) {\n      return input\n    }\n    if (typeof input === \"string\") {\n      const parsed = parseFloat(input.trim())\n      if (!Number.isNaN(parsed)) {\n        return parsed\n      }\n    }\n    return null\n  }\n\n  const parsedValue = parseNumeric(value)\n  const parsedFallback = parseNumeric(fallback)\n  const base = parsedValue === null ? (parsedFallback === null ? maxPercent : parsedFallback) : parsedValue\n  return clamp(Math.round(base), 0, maxPercent)\n}\n\nconst percentToGain = (percent, maxPercent = MAX_VOLUME_PERCENT_DEFAULT) => {\n  const clampedPercent = clamp(Math.round(percent), 0, maxPercent)\n  if (clampedPercent <= 100) {\n    return roundTo(clampedPercent / 100)\n  }\n  const boosted = 1 + (clampedPercent - 100) * 0.01\n  return roundTo(Math.min(boosted, BOOST_CAP))\n}\n\nconst setElementGain = (audio, gain) => {\n  if (!audio) {\n    return\n  }\n\n  const clampedGain = clamp(gain, 0, BOOST_CAP)\n  const elementVolume = Math.min(clampedGain, 1)\n\n  try {\n    audio.volume = elementVolume\n  } catch (_err) {\n    audio.volume = 1\n  }\n\n  if (audio.__gainNode) {\n    audio.__gainNode.gain.value = clampedGain > 1 ? clampedGain : 1\n  }\n}\n\nlet activeLocalPlayer = null\n\nconst stopActiveLocalPlayer = () => {\n  if (activeLocalPlayer && typeof activeLocalPlayer.stopPlayback === \"function\") {\n    activeLocalPlayer.stopPlayback()\n  }\n}\n\nwindow.addEventListener(\"phx:stop-all-sounds\", stopActiveLocalPlayer)\n\nlet Hooks = {}\nHooks.LocalPlayer = {\n  mounted() {\n    this.audio = null\n    this.audioContext = null\n    this.cleanup = null\n    this.handleClick = this.handleClick.bind(this)\n    this.el.addEventListener(\"click\", this.handleClick)\n  },\n  updated() {\n    if (this.audio && !this.audio.paused) {\n      this.configureGain(this.readGain())\n    }\n  },\n  destroyed() {\n    this.el.removeEventListener(\"click\", this.handleClick)\n    this.stopPlayback()\n  },\n  readGain() {\n    const raw = parseFloat(this.el.dataset.volume)\n    return Number.isFinite(raw) ? clamp(raw, 0, BOOST_CAP) : 1\n  },\n  async handleClick(event) {\n    event.preventDefault()\n    event.stopPropagation()\n\n    if (this.audio && !this.audio.paused) {\n      this.stopPlayback()\n      return\n    }\n\n    if (activeLocalPlayer && activeLocalPlayer !== this) {\n      activeLocalPlayer.stopPlayback()\n    }\n\n    await this.startPlayback()\n  },\n  async startPlayback() {\n    this.stopPlayback()\n\n    const sourceType = this.el.dataset.sourceType\n    const url = this.el.dataset.url\n    const filename = this.el.dataset.filename\n\n    const audio = new Audio()\n\n    if (sourceType === \"url\" && url) {\n      audio.src = url\n    } else if (filename) {\n      audio.src = `/uploads/${filename}`\n    } else {\n      return\n    }\n\n    audio.addEventListener(\"ended\", () => this.stopPlayback())\n    audio.addEventListener(\"error\", () => this.stopPlayback())\n\n    this.audio = audio\n\n    await this.configureGain(this.readGain())\n\n    try {\n      await audio.play()\n      this.setPlaying(true)\n      activeLocalPlayer = this\n    } catch (error) {\n      console.error(\"Audio playback failed\", error)\n      this.stopPlayback()\n    }\n  },\n  async configureGain(targetGain) {\n    if (!this.audio) {\n      return\n    }\n\n    this.releaseBoost()\n\n    const normalized = clamp(targetGain, 0, BOOST_CAP)\n    const ContextCtor = getAudioContextCtor()\n\n    if (!ContextCtor || normalized <= 1) {\n      setElementGain(this.audio, normalized)\n      return\n    }\n\n    if (!this.audioContext) {\n      this.audioContext = new ContextCtor()\n    }\n\n    if (this.audioContext.state === \"suspended\") {\n      try {\n        await this.audioContext.resume()\n      } catch (_err) {}\n    }\n\n    try {\n      const source = this.audioContext.createMediaElementSource(this.audio)\n      const gainNode = this.audioContext.createGain()\n      gainNode.gain.value = normalized\n      source.connect(gainNode).connect(this.audioContext.destination)\n      this.audio.__gainNode = gainNode\n      setElementGain(this.audio, normalized)\n\n      this.cleanup = () => {\n        try {\n          source.disconnect()\n        } catch (_err) {}\n        try {\n          gainNode.disconnect()\n        } catch (_err) {}\n        if (this.audio && this.audio.__gainNode === gainNode) {\n          delete this.audio.__gainNode\n        }\n      }\n    } catch (error) {\n      console.warn(\"Unable to apply playback boost\", error)\n      setElementGain(this.audio, Math.min(normalized, 1))\n    }\n  },\n  releaseBoost() {\n    if (typeof this.cleanup === \"function\") {\n      try {\n        this.cleanup()\n      } catch (_err) {}\n    }\n    this.cleanup = null\n    if (this.audio) {\n      delete this.audio.__gainNode\n    }\n  },\n  stopPlayback() {\n    this.releaseBoost()\n    if (this.audio) {\n      try {\n        this.audio.pause()\n        this.audio.currentTime = 0\n      } catch (_err) {}\n      this.audio = null\n    }\n    this.setPlaying(false)\n    if (activeLocalPlayer === this) {\n      activeLocalPlayer = null\n    }\n  },\n  setPlaying(isPlaying) {\n    const playIcon = this.el.querySelector(\".play-icon\")\n    const stopIcon = this.el.querySelector(\".stop-icon\")\n\n    if (!playIcon || !stopIcon) {\n      return\n    }\n\n    if (isPlaying) {\n      playIcon.classList.add(\"hidden\")\n      stopIcon.classList.remove(\"hidden\")\n    } else {\n      playIcon.classList.remove(\"hidden\")\n      stopIcon.classList.add(\"hidden\")\n    }\n  }\n}\n\nHooks.VolumeControl = {\n  mounted() {\n    this.previewAudio = null\n    this.previewContext = null\n    this.previewSource = null\n    this.previewGain = null\n    this.objectUrl = null\n    this.lastFile = null\n    this.pushTimer = null\n    this.previewLabel = \"Preview\"\n\n    this.handleSliderInput = this.handleSliderInput.bind(this)\n    this.handlePreviewClick = this.handlePreviewClick.bind(this)\n\n    this.syncDataset()\n    this.bindElements()\n\n    this.setPercent(this.initialPercent(), {emit: false})\n  },\n  updated() {\n    const previousKind = this.previewKind\n    const previousSrc = this.previewSrc\n\n    this.syncDataset()\n    this.bindElements()\n    this.setPercent(this.initialPercent(), {emit: false})\n\n    if (previousKind && previousKind !== this.previewKind) {\n      this.stopPreview(true)\n    } else if (previousSrc !== this.previewSrc && this.previewKind !== \"local-upload\") {\n      this.stopPreview()\n    }\n  },\n  destroyed() {\n    if (this.slider) {\n      this.slider.removeEventListener(\"input\", this.handleSliderInput)\n    }\n    if (this.previewButton) {\n      this.previewButton.removeEventListener(\"click\", this.handlePreviewClick)\n    }\n    if (this.pushTimer) {\n      clearTimeout(this.pushTimer)\n      this.pushTimer = null\n    }\n    this.stopPreview(true)\n    if (this.previewSource) {\n      try {\n        this.previewSource.disconnect()\n      } catch (_err) {}\n    }\n    if (this.previewGain) {\n      try {\n        this.previewGain.disconnect()\n      } catch (_err) {}\n    }\n    this.previewSource = null\n    this.previewGain = null\n    if (this.previewContext) {\n      try {\n        this.previewContext.close()\n      } catch (_err) {}\n      this.previewContext = null\n    }\n  },\n  syncDataset() {\n    const dataset = this.el.dataset\n    const parsedMax = parseInt(dataset.maxPercent || \"\", 10)\n    this.maxPercent =\n      Number.isInteger(parsedMax) && parsedMax > 0 ? parsedMax : MAX_VOLUME_PERCENT_DEFAULT\n    this.pushEventName = dataset.pushEvent || null\n    this.volumeTarget = dataset.volumeTarget || null\n    this.previewKind = dataset.previewKind || \"existing\"\n    this.fileInputId = dataset.fileInputId || null\n    this.urlInputId = dataset.urlInputId || null\n    this.previewSrc = dataset.previewSrc || \"\"\n  },\n  bindElements() {\n    const slider = this.el.querySelector(\"[data-role='volume-slider']\")\n    if (this.slider !== slider) {\n      if (this.slider) {\n        this.slider.removeEventListener(\"input\", this.handleSliderInput)\n      }\n      this.slider = slider\n      if (this.slider) {\n        this.slider.addEventListener(\"input\", this.handleSliderInput)\n      }\n    }\n\n    const previewButton = this.el.querySelector(\"[data-role='volume-preview']\")\n    if (this.previewButton !== previewButton) {\n      if (this.previewButton) {\n        this.previewButton.removeEventListener(\"click\", this.handlePreviewClick)\n      }\n      this.previewButton = previewButton\n      if (this.previewButton) {\n        this.previewButton.addEventListener(\"click\", this.handlePreviewClick)\n      }\n    }\n\n    if (this.previewButton) {\n      this.previewLabel = this.previewButton.textContent?.trim() || this.previewLabel\n    }\n\n    this.hiddenInput = this.el.querySelector(\"[data-role='volume-hidden']\")\n    this.display = this.el.querySelector(\"[data-role='volume-display']\")\n  },\n  initialPercent() {\n    const hiddenValue = this.hiddenInput?.value\n    const sliderValue = this.slider?.value\n    return parsePercent(hiddenValue ?? sliderValue ?? this.maxPercent, this.maxPercent, this.maxPercent)\n  },\n  setPercent(percent, {emit = false} = {}) {\n    const bounded = clamp(Math.round(percent), 0, this.maxPercent)\n    if (this.slider && Number(this.slider.value) !== bounded) {\n      this.slider.value = bounded\n    }\n\n    if (this.hiddenInput && Number(this.hiddenInput.value) !== bounded) {\n      this.hiddenInput.value = bounded\n    }\n\n    if (this.display) {\n      this.display.textContent = `${bounded}%`\n    }\n\n    if (emit) {\n      this.queuePush(bounded)\n    }\n\n    this.updatePreviewGain(bounded)\n  },\n  async handleSliderInput(event) {\n    const fallback = this.hiddenInput?.value ?? this.slider?.value ?? this.maxPercent\n    const nextPercent = parsePercent(event.target.value, fallback, this.maxPercent)\n    event.target.value = nextPercent\n    this.setPercent(nextPercent, {emit: true})\n  },\n  queuePush(percent) {\n    if (!this.pushEventName) {\n      return\n    }\n\n    if (this.pushTimer) {\n      clearTimeout(this.pushTimer)\n    }\n\n    this.pushTimer = setTimeout(() => {\n      const payload = {volume: percent}\n      if (this.volumeTarget) {\n        payload.target = this.volumeTarget\n      }\n      this.pushEvent(this.pushEventName, payload)\n      this.pushTimer = null\n    }, 100)\n  },\n  async handlePreviewClick(event) {\n    event.preventDefault()\n\n    if (this.previewButton && this.previewButton.disabled) {\n      return\n    }\n\n    if (this.previewAudio && !this.previewAudio.paused) {\n      this.stopPreview()\n      return\n    }\n\n    const src = this.getPreviewSource()\n    if (!src) {\n      return\n    }\n\n    if (!this.previewAudio) {\n      this.previewAudio = new Audio()\n      this.previewAudio.addEventListener(\"ended\", () => this.stopPreview())\n      this.previewAudio.addEventListener(\"error\", () => this.stopPreview())\n    }\n\n    this.previewAudio.src = src\n\n    const percent = parsePercent(\n      this.hiddenInput?.value ?? this.slider?.value ?? this.maxPercent,\n      this.maxPercent,\n      this.maxPercent\n    )\n    const gain = percentToGain(percent, this.maxPercent)\n    await this.ensurePreviewGraph(gain)\n    this.applyPreviewGain(gain)\n\n    try {\n      await this.previewAudio.play()\n      this.setPreviewState(true)\n    } catch (error) {\n      console.error(\"Preview playback failed\", error)\n      this.setPreviewState(false)\n    }\n  },\n  async updatePreviewGain(percent) {\n    const gain = percentToGain(percent, this.maxPercent)\n\n    if (!this.previewAudio) {\n      return\n    }\n\n    await this.ensurePreviewGraph(gain)\n    this.applyPreviewGain(gain)\n  },\n  async ensurePreviewGraph(targetGain) {\n    if (!this.previewAudio) {\n      return\n    }\n\n    const needsBoost = targetGain > 1\n    const ContextCtor = getAudioContextCtor()\n\n    if (!needsBoost || !ContextCtor) {\n      if (this.previewGain) {\n        this.previewGain.gain.value = 1\n      }\n      return\n    }\n\n    if (!this.previewContext) {\n      this.previewContext = new ContextCtor()\n    }\n\n    if (this.previewContext.state === \"suspended\") {\n      try {\n        await this.previewContext.resume()\n      } catch (_err) {}\n    }\n\n    if (!this.previewSource) {\n      try {\n        this.previewSource = this.previewContext.createMediaElementSource(this.previewAudio)\n      } catch (error) {\n        console.warn(\"Preview gain setup failed\", error)\n        this.previewSource = null\n        this.previewGain = null\n        return\n      }\n    }\n\n    if (!this.previewGain) {\n      this.previewGain = this.previewContext.createGain()\n      this.previewSource.connect(this.previewGain).connect(this.previewContext.destination)\n    }\n  },\n  applyPreviewGain(targetGain) {\n    if (!this.previewAudio) {\n      return\n    }\n\n    const base = clamp(targetGain, 0, BOOST_CAP)\n    const volume = Math.min(base, 1)\n\n    try {\n      this.previewAudio.volume = volume\n    } catch (_err) {\n      this.previewAudio.volume = 1\n    }\n\n    if (this.previewGain) {\n      this.previewGain.gain.value = base > 1 ? base : 1\n    }\n  },\n  getPreviewSource() {\n    if (this.previewKind === \"local-upload\" && this.fileInputId) {\n      const input = document.getElementById(this.fileInputId)\n      const file = input && input.files && input.files[0]\n\n      if (!file) {\n        return null\n      }\n\n      if (this.lastFile !== file) {\n        if (this.objectUrl) {\n          URL.revokeObjectURL(this.objectUrl)\n        }\n        this.objectUrl = URL.createObjectURL(file)\n        this.lastFile = file\n      }\n\n      return this.objectUrl\n    }\n\n    if (this.previewKind === \"url\") {\n      if (this.urlInputId) {\n        const urlInput = document.getElementById(this.urlInputId)\n        const value =\n          urlInput && typeof urlInput.value === \"string\" ? urlInput.value.trim() : \"\"\n        if (value) {\n          return value\n        }\n      }\n\n      return this.previewSrc || null\n    }\n\n    return this.previewSrc || null\n  },\n  stopPreview(forceRevoke = false) {\n    if (this.previewAudio) {\n      try {\n        this.previewAudio.pause()\n        this.previewAudio.currentTime = 0\n      } catch (_err) {}\n      this.previewAudio.src = \"\"\n    }\n    this.setPreviewState(false)\n\n    if (forceRevoke && this.objectUrl) {\n      URL.revokeObjectURL(this.objectUrl)\n      this.objectUrl = null\n      this.lastFile = null\n    }\n  },\n  setPreviewState(isPlaying) {\n    if (!this.previewButton) {\n      return\n    }\n\n    this.previewButton.textContent = isPlaying ? \"Stop Preview\" : this.previewLabel\n    this.previewButton.dataset.previewState = isPlaying ? \"playing\" : \"stopped\"\n  }\n}\n\nHooks.CopyButton = {\n  mounted() {\n    this.handleClick = async (e) => {\n      e.preventDefault()\n      const original = this.el.textContent\n      const text =\n        this.el.dataset.copyText ||\n        this.el.getAttribute(\"data-copy-text\") ||\n        (this.el.nextElementSibling ? this.el.nextElementSibling.innerText : \"\")\n      try {\n        if (navigator.clipboard && window.isSecureContext) {\n          await navigator.clipboard.writeText(text)\n        } else {\n          // Fallback for insecure contexts\n          const ta = document.createElement(\"textarea\")\n          ta.value = text\n          ta.style.position = \"fixed\"\n          ta.style.opacity = \"0\"\n          document.body.appendChild(ta)\n          ta.select()\n          document.execCommand(\"copy\")\n          document.body.removeChild(ta)\n        }\n        this.el.textContent = \"Copied!\"\n        this.el.classList.add(\"text-green-600\")\n        setTimeout(() => {\n          this.el.textContent = original\n          this.el.classList.remove(\"text-green-600\")\n        }, 1500)\n      } catch (_err) {\n        this.el.textContent = \"Copy failed\"\n        this.el.classList.add(\"text-red-600\")\n        setTimeout(() => {\n          this.el.textContent = original\n          this.el.classList.remove(\"text-red-600\")\n        }, 1500)\n      }\n    }\n    this.el.addEventListener(\"click\", this.handleClick)\n  },\n  destroyed() {\n    this.el.removeEventListener(\"click\", this.handleClick)\n  }\n}\n\nlet liveSocket = new LiveSocket(\"/live\", Socket, {\n  params: {_csrf_token: csrfToken},\n  hooks: Hooks\n})\n\n// Show progress bar on live navigation and form submits\ntopbar.config({barColors: {0: \"#29d\"}, shadowColor: \"rgba(0, 0, 0, .3)\"})\nwindow.addEventListener(\"phx:page-loading-start\", (_info) => topbar.show(300))\nwindow.addEventListener(\"phx:page-loading-stop\", (_info) => topbar.hide())\n\n// connect if there are any LiveViews on the page\nliveSocket.connect()\n\n// expose liveSocket on window for web console debug logs and latency simulation:\n// >> liveSocket.enableDebug()\n// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session\n// >> liveSocket.disableLatencySim()\nwindow.liveSocket = liveSocket\n\nif (window.navigator.standalone) {\n  document.documentElement.style.setProperty(\"--sat\", \"env(safe-area-inset-top)\")\n  document.documentElement.classList.add(\"standalone\")\n}\n"
  },
  {
    "path": "assets/js/hooks/local_player.js",
    "content": "const LocalPlayer = {\n  currentAudio: null,\n  currentButton: null,\n\n  mounted() {\n    this.el.addEventListener(\"click\", () => {\n      const filename = this.el.dataset.filename;\n      \n      // If clicking the same button that's currently playing\n      if (LocalPlayer.currentButton === this.el && LocalPlayer.currentAudio) {\n        // Stop the audio and reset the button\n        LocalPlayer.currentAudio.pause();\n        LocalPlayer.currentAudio.currentTime = 0;\n        LocalPlayer.currentAudio = null;\n        this.updateIcon(false);\n        LocalPlayer.currentButton = null;\n        return;\n      }\n\n      // If a different audio is playing, stop it and reset its button\n      if (LocalPlayer.currentAudio && LocalPlayer.currentButton) {\n        LocalPlayer.currentAudio.pause();\n        LocalPlayer.currentAudio.currentTime = 0;\n        LocalPlayer.currentButton.querySelector('svg').outerHTML = this.playIcon();\n      }\n\n      // Play the new audio\n      LocalPlayer.currentAudio = new Audio(`/uploads/${filename}`);\n      LocalPlayer.currentButton = this.el;\n      LocalPlayer.currentAudio.play();\n      this.updateIcon(true);\n\n      // When audio ends, reset the button\n      LocalPlayer.currentAudio.onended = () => {\n        this.updateIcon(false);\n        LocalPlayer.currentAudio = null;\n        LocalPlayer.currentButton = null;\n      };\n    });\n  },\n\n  updateIcon(isPlaying) {\n    this.el.querySelector('svg').outerHTML = isPlaying ? this.stopIcon() : this.playIcon();\n  },\n\n  playIcon() {\n    return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" class=\"w-4 h-4\">\n      <path d=\"M12 15a3 3 0 100-6 3 3 0 000 6z\" />\n      <path fill-rule=\"evenodd\" d=\"M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 010-1.113zM17.25 12a5.25 5.25 0 11-10.5 0 5.25 5.25 0 0110.5 0z\" clip-rule=\"evenodd\" />\n    </svg>`;\n  },\n\n  stopIcon() {\n    return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" class=\"w-4 h-4\">\n      <path fill-rule=\"evenodd\" d=\"M4.5 7.5a3 3 0 013-3h9a3 3 0 013 3v9a3 3 0 01-3 3h-9a3 3 0 01-3-3v-9z\" clip-rule=\"evenodd\" />\n    </svg>`;\n  }\n}\n\nexport default LocalPlayer; "
  },
  {
    "path": "assets/tailwind.config.js",
    "content": "// See the Tailwind configuration guide for advanced usage\n// https://tailwindcss.com/docs/configuration\n\nconst plugin = require(\"tailwindcss/plugin\")\nconst fs = require(\"fs\")\nconst path = require(\"path\")\n\nmodule.exports = {\n  content: [\n    \"./js/**/*.js\",\n    \"../lib/*_web/**/*.*ex\"\n  ],\n  theme: {\n    extend: {\n      colors: {\n        brand: \"#FD4F00\",\n      },\n      animation: {\n        'fade-in': 'fadeIn 0.3s ease-in',\n        'fade-out': 'fadeOut 0.3s ease-out',\n      },\n      keyframes: {\n        fadeIn: {\n          '0%': { opacity: '0' },\n          '100%': { opacity: '0.3' },\n        },\n        fadeOut: {\n          '0%': { opacity: '0.3' },\n          '100%': { opacity: '0' },\n        },\n      },\n    },\n  },\n  plugins: [\n    require(\"@tailwindcss/forms\"),\n    // Allows prefixing tailwind classes with LiveView classes to add rules\n    // only when LiveView classes are applied, for example:\n    //\n    //     <div class=\"phx-click-loading:animate-ping\">\n    //\n    plugin(({addVariant}) => addVariant(\"phx-click-loading\", [\".phx-click-loading&\", \".phx-click-loading &\"])),\n    plugin(({addVariant}) => addVariant(\"phx-submit-loading\", [\".phx-submit-loading&\", \".phx-submit-loading &\"])),\n    plugin(({addVariant}) => addVariant(\"phx-change-loading\", [\".phx-change-loading&\", \".phx-change-loading &\"])),\n\n    // Embeds Heroicons (https://heroicons.com) into your app.css bundle\n    // See your `CoreComponents.icon/1` for more information.\n    //\n    plugin(function({matchComponents, theme}) {\n      let iconsDir = path.join(__dirname, \"../deps/heroicons/optimized\")\n      let values = {}\n      let icons = [\n        [\"\", \"/24/outline\"],\n        [\"-solid\", \"/24/solid\"],\n        [\"-mini\", \"/20/solid\"],\n        [\"-micro\", \"/16/solid\"]\n      ]\n      icons.forEach(([suffix, dir]) => {\n        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {\n          let name = path.basename(file, \".svg\") + suffix\n          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}\n        })\n      })\n      matchComponents({\n        \"hero\": ({name, fullPath}) => {\n          let content = fs.readFileSync(fullPath).toString().replace(/\\r?\\n|\\r/g, \"\")\n          let size = theme(\"spacing.6\")\n          if (name.endsWith(\"-mini\")) {\n            size = theme(\"spacing.5\")\n          } else if (name.endsWith(\"-micro\")) {\n            size = theme(\"spacing.4\")\n          }\n          return {\n            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,\n            \"-webkit-mask\": `var(--hero-${name})`,\n            \"mask\": `var(--hero-${name})`,\n            \"mask-repeat\": \"no-repeat\",\n            \"background-color\": \"currentColor\",\n            \"vertical-align\": \"middle\",\n            \"display\": \"inline-block\",\n            \"width\": size,\n            \"height\": size\n          }\n        }\n      }, {values})\n    })\n  ]\n}\n"
  },
  {
    "path": "assets/vendor/topbar.js",
    "content": "/**\n * @license MIT\n * topbar 2.0.0, 2023-02-04\n * https://buunguyen.github.io/topbar\n * Copyright (c) 2021 Buu Nguyen\n */\n(function (window, document) {\n  \"use strict\";\n\n  // https://gist.github.com/paulirish/1579671\n  (function () {\n    var lastTime = 0;\n    var vendors = [\"ms\", \"moz\", \"webkit\", \"o\"];\n    for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {\n      window.requestAnimationFrame =\n        window[vendors[x] + \"RequestAnimationFrame\"];\n      window.cancelAnimationFrame =\n        window[vendors[x] + \"CancelAnimationFrame\"] ||\n        window[vendors[x] + \"CancelRequestAnimationFrame\"];\n    }\n    if (!window.requestAnimationFrame)\n      window.requestAnimationFrame = function (callback, element) {\n        var currTime = new Date().getTime();\n        var timeToCall = Math.max(0, 16 - (currTime - lastTime));\n        var id = window.setTimeout(function () {\n          callback(currTime + timeToCall);\n        }, timeToCall);\n        lastTime = currTime + timeToCall;\n        return id;\n      };\n    if (!window.cancelAnimationFrame)\n      window.cancelAnimationFrame = function (id) {\n        clearTimeout(id);\n      };\n  })();\n\n  var canvas,\n    currentProgress,\n    showing,\n    progressTimerId = null,\n    fadeTimerId = null,\n    delayTimerId = null,\n    addEvent = function (elem, type, handler) {\n      if (elem.addEventListener) elem.addEventListener(type, handler, false);\n      else if (elem.attachEvent) elem.attachEvent(\"on\" + type, handler);\n      else elem[\"on\" + type] = handler;\n    },\n    options = {\n      autoRun: true,\n      barThickness: 3,\n      barColors: {\n        0: \"rgba(26,  188, 156, .9)\",\n        \".25\": \"rgba(52,  152, 219, .9)\",\n        \".50\": \"rgba(241, 196, 15,  .9)\",\n        \".75\": \"rgba(230, 126, 34,  .9)\",\n        \"1.0\": \"rgba(211, 84,  0,   .9)\",\n      },\n      shadowBlur: 10,\n      shadowColor: \"rgba(0,   0,   0,   .6)\",\n      className: null,\n    },\n    repaint = function () {\n      canvas.width = window.innerWidth;\n      canvas.height = options.barThickness * 5; // need space for shadow\n\n      var ctx = canvas.getContext(\"2d\");\n      ctx.shadowBlur = options.shadowBlur;\n      ctx.shadowColor = options.shadowColor;\n\n      var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);\n      for (var stop in options.barColors)\n        lineGradient.addColorStop(stop, options.barColors[stop]);\n      ctx.lineWidth = options.barThickness;\n      ctx.beginPath();\n      ctx.moveTo(0, options.barThickness / 2);\n      ctx.lineTo(\n        Math.ceil(currentProgress * canvas.width),\n        options.barThickness / 2\n      );\n      ctx.strokeStyle = lineGradient;\n      ctx.stroke();\n    },\n    createCanvas = function () {\n      canvas = document.createElement(\"canvas\");\n      var style = canvas.style;\n      style.position = \"fixed\";\n      style.top = style.left = style.right = style.margin = style.padding = 0;\n      style.zIndex = 100001;\n      style.display = \"none\";\n      if (options.className) canvas.classList.add(options.className);\n      document.body.appendChild(canvas);\n      addEvent(window, \"resize\", repaint);\n    },\n    topbar = {\n      config: function (opts) {\n        for (var key in opts)\n          if (options.hasOwnProperty(key)) options[key] = opts[key];\n      },\n      show: function (delay) {\n        if (showing) return;\n        if (delay) {\n          if (delayTimerId) return;\n          delayTimerId = setTimeout(() => topbar.show(), delay);\n        } else  {\n          showing = true;\n          if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);\n          if (!canvas) createCanvas();\n          canvas.style.opacity = 1;\n          canvas.style.display = \"block\";\n          topbar.progress(0);\n          if (options.autoRun) {\n            (function loop() {\n              progressTimerId = window.requestAnimationFrame(loop);\n              topbar.progress(\n                \"+\" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)\n              );\n            })();\n          }\n        }\n      },\n      progress: function (to) {\n        if (typeof to === \"undefined\") return currentProgress;\n        if (typeof to === \"string\") {\n          to =\n            (to.indexOf(\"+\") >= 0 || to.indexOf(\"-\") >= 0\n              ? currentProgress\n              : 0) + parseFloat(to);\n        }\n        currentProgress = to > 1 ? 1 : to;\n        repaint();\n        return currentProgress;\n      },\n      hide: function () {\n        clearTimeout(delayTimerId);\n        delayTimerId = null;\n        if (!showing) return;\n        showing = false;\n        if (progressTimerId != null) {\n          window.cancelAnimationFrame(progressTimerId);\n          progressTimerId = null;\n        }\n        (function loop() {\n          if (topbar.progress(\"+.1\") >= 1) {\n            canvas.style.opacity -= 0.05;\n            if (canvas.style.opacity <= 0.05) {\n              canvas.style.display = \"none\";\n              fadeTimerId = null;\n              return;\n            }\n          }\n          fadeTimerId = window.requestAnimationFrame(loop);\n        })();\n      },\n    };\n\n  if (typeof module === \"object\" && typeof module.exports === \"object\") {\n    module.exports = topbar;\n  } else if (typeof define === \"function\" && define.amd) {\n    define(function () {\n      return topbar;\n    });\n  } else {\n    this.topbar = topbar;\n  }\n}.call(this, window, document));\n"
  },
  {
    "path": "config/config.exs",
    "content": "# This file is responsible for configuring your application\n# and its dependencies with the aid of the Config module.\n#\n# This configuration file is loaded before any dependency and\n# is restricted to this project.\n\n# General application configuration\nimport Config\n\n# config :soundboard,\n#   ecto_repos: [Soundboard.Repo],\n#   generators: [timestamp_type: :utc_datetime],\n#   token: System.get_env(\"DISCORD_TOKEN\")\n\n# EDA config shared across environments.\n# Prefix commands like `!join` require :message_content intent.\nconfig :eda,\n  intents: [:guilds, :guild_messages, :guild_voice_states, :message_content],\n  consumer: Soundboard.Discord.Consumer,\n  gateway_encoding: :etf,\n  dave: true\n\n# Configures the endpoint\nconfig :soundboard, SoundboardWeb.Endpoint,\n  url: [host: \"localhost\", port: 4000],\n  adapter: Bandit.PhoenixAdapter,\n  render_errors: [\n    formats: [html: SoundboardWeb.ErrorHTML, json: SoundboardWeb.ErrorJSON],\n    layout: false\n  ],\n  pubsub_server: Soundboard.PubSub,\n  live_view: [signing_salt: \"9gxiIiqP\"]\n\n# Configure esbuild (the version is required)\nconfig :esbuild,\n  version: \"0.17.11\",\n  soundboard: [\n    args:\n      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),\n    cd: Path.expand(\"../assets\", __DIR__),\n    env: %{\"NODE_PATH\" => Path.expand(\"../deps\", __DIR__)}\n  ]\n\n# Configure tailwind (the version is required)\nconfig :tailwind,\n  version: \"3.4.3\",\n  soundboard: [\n    args: ~w(\n      --config=tailwind.config.js\n      --input=css/app.css\n      --output=../priv/static/assets/app.css\n    ),\n    cd: Path.expand(\"../assets\", __DIR__)\n  ]\n\n# Use Jason for JSON parsing in Phoenix\nconfig :phoenix, :json_library, Jason\n\n# Add MIME types for audio files\nconfig :mime, :types, %{\n  \"audio/mpeg\" => [\"mp3\"],\n  \"audio/ogg\" => [\"ogg\"],\n  \"audio/wav\" => [\"wav\"],\n  \"audio/x-m4a\" => [\"m4a\"]\n}\n\n# Import environment specific config. This must remain at the bottom\n# of this file so it overrides the configuration defined above.\nimport_config \"#{config_env()}.exs\"\n\n# Add this near the top of the file\nconfig :soundboard,\n  ecto_repos: [Soundboard.Repo]\n\n# Add this somewhere in the file\nconfig :soundboard, Soundboard.Repo,\n  database: \"priv/static/uploads/database.db\",\n  pool_size: 5\n\nconfig :phoenix_live_view,\n  flash_timeout: 3000\n\nconfig :soundboard, SoundboardWeb.Presence, pubsub_server: Soundboard.PubSub\n\n# Optional voice startup probe (disabled by default)\nconfig :soundboard,\n  voice_rtp_probe: false,\n  voice_rtp_probe_timeout_ms: 6_000\n\n# Add this with your other configs\nconfig :ueberauth, Ueberauth,\n  providers: [\n    discord: {Ueberauth.Strategy.Discord, [default_scope: \"identify\"]}\n  ]\n"
  },
  {
    "path": "config/dev.exs",
    "content": "import Config\n\nconfig :soundboard, Soundboard.Repo,\n  database: \"database.db\",\n  adapter: Ecto.Adapters.SQLite3\n\ngenerate_secret_key_base = fn ->\n  Base.encode64(:crypto.strong_rand_bytes(64), padding: false)\nend\n\nderive_secret_key_base = fn value ->\n  :crypto.hash(:sha512, value)\n  |> Base.encode64(padding: false)\nend\n\nsecret_key_base =\n  case System.get_env(\"SECRET_KEY_BASE\") do\n    value when is_binary(value) and byte_size(value) >= 64 ->\n      value\n\n    value when is_binary(value) ->\n      derive_secret_key_base.(value)\n\n    _ ->\n      generate_secret_key_base.()\n  end\n\nconfig :soundboard, SoundboardWeb.Endpoint,\n  http: [ip: {127, 0, 0, 1}, port: 4000],\n  url: [host: \"localhost\", port: 4000, scheme: \"http\"],\n  check_origin: false,\n  code_reloader: true,\n  debug_errors: true,\n  secret_key_base: secret_key_base,\n  watchers: [\n    esbuild: {Esbuild, :install_and_run, [:soundboard, ~w(--sourcemap=inline --watch)]},\n    tailwind: {Tailwind, :install_and_run, [:soundboard, ~w(--watch)]}\n  ]\n\nconfig :soundboard, SoundboardWeb.Endpoint,\n  live_reload: [\n    patterns: [\n      ~r\"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$\",\n      ~r\"priv/gettext/.*(po)$\",\n      ~r\"lib/soundboard_web/(controllers|live|components)/.*(ex|heex)$\"\n    ]\n  ]\n\nconfig :soundboard, dev_routes: true\nconfig :logger, :console, format: \"[$level] $message\\n\"\nconfig :phoenix, :stacktrace_depth, 20\nconfig :phoenix, :plug_init_mode, :runtime\n\nconfig :phoenix_live_view,\n  debug_heex_annotations: true,\n  enable_expensive_runtime_checks: true\n\nconfig :swoosh, :api_client, false\nconfig :soundboard, env: :dev\n"
  },
  {
    "path": "config/prod.exs",
    "content": "import Config\n\n# Note we also include the path to a cache manifest\n# containing the digested version of static files. This\n# manifest is generated by the `mix assets.deploy` task,\n# which you should run after static files are built and\n# before starting your production server.\nconfig :soundboard, SoundboardWeb.Endpoint,\n  cache_static_manifest: \"priv/static/cache_manifest.json\"\n\nconfig :soundboard, Soundboard.Repo,\n  database: \"/app/priv/static/uploads/soundboard_prod.db\",\n  pool_size: 5,\n  stacktrace: true,\n  show_sensitive_data_on_connection_error: true\n\n# Ensure the uploads directory exists\nconfig :soundboard,\n  upload_directory: \"/app/priv/static/uploads\"\n\nconfig :soundboard,\n  env: :prod\n\n# Configure logging for production - enable debug level for voice troubleshooting\nconfig :logger, level: :debug\n"
  },
  {
    "path": "config/runtime.exs",
    "content": "import Config\nimport Dotenvy\n\nenv_dir_prefix = System.get_env(\"RELEASE_ROOT\") || Path.expand(\".\")\n\nsource!([\n  Path.absname(\".env\", env_dir_prefix),\n  Path.absname(\".#{config_env()}.env\", env_dir_prefix),\n  Path.absname(\".#{config_env()}.overrides.env\", env_dir_prefix),\n  System.get_env()\n])\n\n# config/runtime.exs is executed for all environments, including\n# during releases. It is executed after compilation and before the\n# system starts, so it is typically used to load production configuration\n# and secrets from environment variables or elsewhere. Do not define\n# any compile-time configuration in here, as it won't be applied.\n\n# ## Using releases\n#\n# If you use `mix release`, you need to explicitly enable the server\n# by passing the PHX_SERVER=true when you start it:\n#\n#     PHX_SERVER=true bin/soundboard start\n#\n# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`\n# script that automatically sets the env var above.\nif env!(\"PHX_SERVER\", :boolean, false) do\n  config :soundboard, SoundboardWeb.Endpoint, server: true\nend\n\nif config_env() == :dev do\n  host = env!(\"PHX_HOST\", :string!, \"localhost:4000\")\n  scheme = env!(\"SCHEME\", :string!, \"http\")\n  port = env!(\"PORT\", :integer, 4000)\n  callback_url = \"#{scheme}://#{host}/auth/discord/callback\"\n  discord_token = env!(\"DISCORD_TOKEN\", :string!, nil)\n  client_id = env!(\"DISCORD_CLIENT_ID\", :string!, nil)\n  client_secret = env!(\"DISCORD_CLIENT_SECRET\", :string!, nil)\n  eda_dave = env!(\"EDA_DAVE\", :boolean, true)\n  voice_rtp_probe = env!(\"VOICE_RTP_PROBE\", :boolean, false)\n  voice_rtp_probe_timeout_ms = env!(\"VOICE_RTP_PROBE_TIMEOUT_MS\", :integer, 6_000)\n\n  secret_key_base =\n    case env!(\"SECRET_KEY_BASE\", :string!, nil) do\n      value when is_binary(value) and byte_size(value) >= 64 ->\n        value\n\n      value when is_binary(value) ->\n        :crypto.hash(:sha512, value)\n        |> Base.encode64(padding: false)\n\n      _ ->\n        nil\n    end\n\n  bind_ip =\n    case env!(\"BIND_IP\", :string!, \"127.0.0.1\")\n         |> String.to_charlist()\n         |> :inet.parse_address() do\n      {:ok, ip_tuple} -> ip_tuple\n      _ -> {127, 0, 0, 1}\n    end\n\n  endpoint_overrides = [\n    url: [host: host, port: port, scheme: scheme],\n    http: [ip: bind_ip, port: port]\n  ]\n\n  endpoint_overrides =\n    if is_binary(secret_key_base) do\n      Keyword.put(endpoint_overrides, :secret_key_base, secret_key_base)\n    else\n      endpoint_overrides\n    end\n\n  config :soundboard, SoundboardWeb.Endpoint, endpoint_overrides\n\n  config :ueberauth, Ueberauth.Strategy.Discord.OAuth,\n    client_id: client_id,\n    client_secret: client_secret,\n    redirect_uri: callback_url\n\n  ffmpeg_available = not is_nil(System.find_executable(\"ffmpeg\"))\n\n  unless ffmpeg_available do\n    IO.warn(\n      \"ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed.\"\n    )\n  end\n\n  required_guild_id = env!(\"DISCORD_REQUIRED_GUILD_ID\", :string, nil)\n\n  required_role_ids =\n    \"DISCORD_REQUIRED_ROLE_IDS\"\n    |> env!(:string, \"\")\n    |> String.split(\",\", trim: true)\n\n  role_recheck_interval_seconds = env!(\"DISCORD_ROLE_RECHECK_INTERVAL_SECONDS\", :integer, 900)\n\n  config :soundboard,\n    discord_token: discord_token,\n    voice_rtp_probe: voice_rtp_probe,\n    voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,\n    ffmpeg_available: ffmpeg_available,\n    required_guild_id: required_guild_id,\n    required_role_ids: required_role_ids,\n    role_recheck_interval_seconds: role_recheck_interval_seconds\n\n  config :eda,\n    token: discord_token,\n    dave: eda_dave\nend\n\n# Allow build tooling to opt-out to avoid requiring secrets during image builds.\nif config_env() == :prod and is_nil(env!(\"SKIP_RUNTIME_CONFIG\", :string, nil)) do\n  port = env!(\"PORT\", :integer, 4000)\n\n  # Replace the database_url section with SQLite configuration\n  database_path = Path.join(:code.priv_dir(:soundboard), \"static/uploads/soundboard_prod.db\")\n\n  config :soundboard, Soundboard.Repo,\n    database: database_path,\n    adapter: Ecto.Adapters.SQLite3,\n    pool_size: env!(\"POOL_SIZE\", :integer, 10)\n\n  # The secret key base is used to sign/encrypt cookies and other secrets.\n  secret_key_base =\n    case env!(\"SECRET_KEY_BASE\", :string!, nil) do\n      value when is_binary(value) ->\n        value\n\n      _ ->\n        case env!(\"SECRET_KEY_BASE_FILE\", :string!, nil) do\n          file when is_binary(file) ->\n            case File.read(file) do\n              {:ok, key} ->\n                String.trim(key)\n\n              {:error, reason} ->\n                raise \"\"\"\n                could not read SECRET_KEY_BASE_FILE (#{file}): #{inspect(reason)}\n                \"\"\"\n            end\n\n          _ ->\n            raise \"\"\"\n            environment variable SECRET_KEY_BASE is missing.\n            Provide it via your environment (recommended) or set SECRET_KEY_BASE_FILE to a file path containing the key.\n            Generate one with: mix phx.gen.secret OR openssl rand -base64 48\n            \"\"\"\n        end\n    end\n\n  host = env!(\"PHX_HOST\", :string!)\n  scheme = env!(\"SCHEME\", :string!, \"https\")\n  callback_url = \"#{scheme}://#{host}/auth/discord/callback\"\n\n  # Configure endpoint first\n  config :soundboard, SoundboardWeb.Endpoint,\n    # In prod, PHX_HOST represents the externally visible host. Do not append\n    # the app's internal listen port unless the host itself already includes one.\n    url: [\n      scheme: scheme,\n      host: host,\n      port: nil\n    ],\n    http: [\n      ip: {0, 0, 0, 0},\n      port: port\n    ],\n    static_url: [\n      host: host,\n      port: nil\n    ],\n    check_origin: false,\n    force_ssl: scheme == \"https\",\n    secret_key_base: secret_key_base,\n    session: [\n      store: :cookie,\n      key: \"_soundboard_key\",\n      signing_salt: secret_key_base\n    ]\n\n  # Configure Ueberauth\n  config :ueberauth, Ueberauth,\n    providers: [\n      discord: {Ueberauth.Strategy.Discord, [default_scope: \"identify\"]}\n    ]\n\n  # Configure Discord OAuth\n  config :ueberauth, Ueberauth.Strategy.Discord.OAuth,\n    client_id: env!(\"DISCORD_CLIENT_ID\", :string!),\n    client_secret: env!(\"DISCORD_CLIENT_SECRET\", :string!),\n    redirect_uri: callback_url\n\n  # Configure Discord bot token\n  discord_token = env!(\"DISCORD_TOKEN\", :string!)\n\n  # Store token for application use (bot will fetch it from here)\n  voice_rtp_probe = env!(\"VOICE_RTP_PROBE\", :boolean, false)\n  voice_rtp_probe_timeout_ms = env!(\"VOICE_RTP_PROBE_TIMEOUT_MS\", :integer, 6_000)\n  eda_dave = env!(\"EDA_DAVE\", :boolean, true)\n\n  ffmpeg_available = not is_nil(System.find_executable(\"ffmpeg\"))\n\n  unless ffmpeg_available do\n    IO.warn(\n      \"ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed.\"\n    )\n  end\n\n  required_guild_id = env!(\"DISCORD_REQUIRED_GUILD_ID\", :string, nil)\n\n  required_role_ids =\n    \"DISCORD_REQUIRED_ROLE_IDS\"\n    |> env!(:string, \"\")\n    |> String.split(\",\", trim: true)\n\n  role_recheck_interval_seconds = env!(\"DISCORD_ROLE_RECHECK_INTERVAL_SECONDS\", :integer, 900)\n\n  config :soundboard,\n    discord_token: discord_token,\n    voice_rtp_probe: voice_rtp_probe,\n    voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,\n    ffmpeg_available: ffmpeg_available,\n    required_guild_id: required_guild_id,\n    required_role_ids: required_role_ids,\n    role_recheck_interval_seconds: role_recheck_interval_seconds\n\n  config :eda,\n    token: discord_token,\n    dave: eda_dave\n\n  # Configure logger for production\n  config :logger,\n    # Set minimum log level to debug to see IO.puts\n    level: :debug,\n    compile_time_purge_matching: [\n      # Don't purge debug logs\n      [level_lower_than: :debug]\n    ]\n\n  config :logger, :console,\n    format: \"$time $metadata[$level] $message\\n\",\n    metadata: [:request_id, :error],\n    # Enable colors for better visibility\n    colors: [enabled: true]\n\n  # Keep stacktraces in production for better error reporting\n  config :phoenix,\n    stacktrace_depth: 20,\n    plug_init_mode: :runtime\n\n  config :soundboard, :env, :prod\nend\n"
  },
  {
    "path": "config/test.exs",
    "content": "import Config\n\n# Configure your database\n#\n# The MIX_TEST_PARTITION environment variable can be used\n# to provide built-in test partitioning in CI environment.\n# Run `mix help test` for more information.\nconfig :soundboard, Soundboard.Repo,\n  adapter: Ecto.Adapters.SQLite3,\n  database: Path.expand(\"../soundboard_test.db\", Path.dirname(__ENV__.file)),\n  pool: Ecto.Adapters.SQL.Sandbox,\n  pool_size: 1,\n  busy_timeout: 5000,\n  journal_mode: :wal\n\n# We don't run a server during test. If one is required,\n# you can enable the server option below.\ngenerate_secret_key_base = fn ->\n  Base.encode64(:crypto.strong_rand_bytes(64), padding: false)\nend\n\nderive_secret_key_base = fn value ->\n  :crypto.hash(:sha512, value)\n  |> Base.encode64(padding: false)\nend\n\nsecret_key_base =\n  case System.get_env(\"SECRET_KEY_BASE_TEST\") do\n    value when is_binary(value) and byte_size(value) >= 64 ->\n      value\n\n    value when is_binary(value) ->\n      derive_secret_key_base.(value)\n\n    _ ->\n      generate_secret_key_base.()\n  end\n\nconfig :soundboard, SoundboardWeb.Endpoint,\n  http: [ip: {127, 0, 0, 1}, port: 4002],\n  secret_key_base: secret_key_base,\n  server: false\n\n# Print only warnings and errors during test\nconfig :logger, level: :warning\n# Configure the console backend\nconfig :logger, :console,\n  format: \"$time $metadata[$level] $message\\n\",\n  metadata: [:request_id, :file, :line]\n\n# Initialize plugs at runtime for faster test compilation\nconfig :phoenix, :plug_init_mode, :runtime\n\n# Enable helpful, but potentially expensive runtime checks\nconfig :phoenix_live_view,\n  enable_expensive_runtime_checks: true\n\nconfig :soundboard, :sql_sandbox, true\n\nconfig :soundboard, env: :test\n\nconfig :soundboard, Soundboard.AudioPlayer, voice_maintenance_enabled: false\n\nconfig :soundboard, Soundboard.PubSub,\n  adapter: Phoenix.PubSub.PG2,\n  name: Soundboard.PubSub\n\nconfig :eda,\n  token: nil,\n  consumer: nil,\n  dave: false\n"
  },
  {
    "path": "coveralls.json",
    "content": "{\n  \"skip_files\": [\n    \"lib/soundboard_web/presence.ex\",\n    \"lib/soundboard_web/telemetry.ex\",\n    \"lib/soundboard_web/gettext.ex\",\n    \"lib/soundboard_web/endpoint.ex\",\n    \"lib/soundboard_web/router.ex\",\n    \"lib/soundboard_web/components/\",\n    \"lib/soundboard_web/live/\",\n    \"lib/soundboard_web/audio_player.ex\",\n    \"lib/soundboard_web/discord_handler.ex\",\n    \"lib/soundboard_web/controllers/upload_controller.ex\",\n    \"lib/soundboard_web/controllers/error_html.ex\",\n    \"lib/soundboard_web/controllers/error_json.ex\",\n    \"lib/soundboard_web/eda_consumer.ex\",\n    \"lib/soundboard/discord/guild_cache.ex\",\n    \"lib/soundboard/discord/message.ex\",\n    \"lib/soundboard/discord/self.ex\",\n    \"lib/soundboard/discord/voice.ex\",\n    \"lib/soundboard/audio_player.ex\",\n    \"lib/soundboard/audio_player/playback_engine.ex\",\n    \"lib/soundboard/discord/handler/voice_runtime.ex\",\n    \"lib/soundboard/release.ex\",\n    \"lib/soundboard/repo.ex\",\n    \"lib/soundboard/application.ex\",\n    \"lib/soundboard_web.ex\",\n    \"test\"\n  ]\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  soundbored:\n    image: christom/soundbored:latest\n    ports:\n      - \"127.0.0.1:4000:4000\"\n    env_file: .env\n    volumes:\n      - soundbored_data:/app/priv/static/uploads\n    # the below can be added for improved security\n      - type: tmpfs\n        target: /tmp/mix_pubsub\n        tmpfs:\n          mode: 0777\n    user: 9999:9999 # make sure this user has permissions on the soundbored_data volume\n    read_only: true\n    environment:\n      TMPDIR: /tmp/mix_pubsub\n      SECRET_KEY_BASE_FILE: /run/secrets/soundbored_secret_key_base\n    secrets:\n      - soundbored_secret_key_base\n\nvolumes:\n  soundbored_data:\n\nsecrets:\n  soundbored_secret_key_base:\n    # this file should contain securely-generated random data (see README.md)\n    # and only be readable by root or the user running the docker daemon\n    file: ./secret_key_base.txt\n"
  },
  {
    "path": "docs/plans/discord-role-gated-access.md",
    "content": "# Discord Role-Gated Access Implementation Plan\n\n**Status:** Complete\n**Spec:** `docs/specs/discord-role-gated-access.md`\n\n**Goal:** Restrict web access to Discord users who hold a configured role in a configured guild, with verification at login and periodic re-checks.\n\n---\n\n## Tasks\n\n### Task 1: Config plumbing ✓\nAdded `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`.\n\n### Task 2: Soundboard.Discord.RoleChecker ✓\nNew 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.\nTests: `test/soundboard/discord/role_checker_test.exs` (8 tests).\n\n### Task 3: Login-time role check in AuthController ✓\n`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.\nTests: `test/soundboard_web/controllers/auth_controller_test.exs` (updated).\n\n### Task 4: SoundboardWeb.Plugs.RoleCheck ✓\nNew 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.\nTests: `test/soundboard_web/plugs/role_check_test.exs` (6 tests).\n\n### Task 5: Wire plug into router ✓\nAdded `:require_role_check` pipeline to `lib/soundboard_web/router.ex`, inserted into both protected scopes after `:ensure_authenticated_user`.\n"
  },
  {
    "path": "docs/plans/discord-role-gated-access.md.tasks.json",
    "content": "{\n  \"planPath\": \"docs/plans/discord-role-gated-access.md\",\n  \"tasks\": [\n    {\n      \"id\": 2,\n      \"subject\": \"Task 1: Add config plumbing for required guild/role env vars\",\n      \"status\": \"completed\",\n      \"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```\"\n    },\n    {\n      \"id\": 3,\n      \"subject\": \"Task 2: Implement Soundboard.Discord.RoleChecker with TDD\",\n      \"status\": \"completed\",\n      \"blockedBy\": [2],\n      \"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```\"\n    },\n    {\n      \"id\": 4,\n      \"subject\": \"Task 3: Add login-time role check to AuthController\",\n      \"status\": \"completed\",\n      \"blockedBy\": [3],\n      \"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```\"\n    },\n    {\n      \"id\": 5,\n      \"subject\": \"Task 4: Implement SoundboardWeb.Plugs.RoleCheck with TDD\",\n      \"status\": \"completed\",\n      \"blockedBy\": [3],\n      \"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```\"\n    },\n    {\n      \"id\": 6,\n      \"subject\": \"Task 5: Wire RoleCheck plug into protected pipeline\",\n      \"status\": \"completed\",\n      \"blockedBy\": [5],\n      \"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```\"\n    }\n  ],\n  \"lastUpdated\": \"2026-04-27T17:11:00Z\"\n}\n"
  },
  {
    "path": "docs/specs/discord-role-gated-access.md",
    "content": "# Spec: Discord Role-Gated Access\n\n**Status:** Implemented\n**Date:** 2026-04-27\n\n---\n\n## Summary\n\nRestrict 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.\n\n---\n\n## Configuration\n\nThree env vars parsed in `config/runtime.exs`:\n\n| Env Var | Type | Default | Description |\n|---|---|---|---|\n| `DISCORD_REQUIRED_GUILD_ID` | string | `nil` | Guild snowflake ID |\n| `DISCORD_REQUIRED_ROLE_IDS` | comma-separated strings | `\"\"` | Role snowflake IDs; user must hold at least one |\n| `DISCORD_ROLE_RECHECK_INTERVAL_SECONDS` | integer | `900` | Seconds between role re-checks per session |\n\nFeature 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.\n\n---\n\n## Behavior\n\n| Scenario | Result |\n|---|---|\n| Feature not configured | Open access — current behavior preserved |\n| User authenticates, has a required role | Logged in normally |\n| User authenticates, lacks required role | No user record created; redirected to `/` with `\"Error signing in\"` flash |\n| Logged-in user loses required role | At next re-check, session cleared, redirected to `/` |\n| Discord API call fails | Fails closed — user denied |\n\nThe flash message is intentionally generic to avoid leaking why access was denied.\n\n---\n\n## Architecture\n\n**`Soundboard.Discord.RoleChecker`** (`lib/soundboard/discord/role_checker.ex`)\nWraps `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.\n\n**`SoundboardWeb.AuthController`**\nThe 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.\n\n**`SoundboardWeb.Plugs.RoleCheck`** (`lib/soundboard_web/plugs/role_check.ex`)\nRuns 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.\n\n**Router:** New `:require_role_check` pipeline wired into both protected scopes (`/` and `/uploads`) after `:ensure_authenticated_user`.\n\n---\n\n## Out of Scope\n\n- Per-LiveView/per-action authorization\n- Multi-guild support\n- Re-checking on WebSocket lifecycle (LiveView mount)\n- Dedicated denial page\n"
  },
  {
    "path": "docs/specs/voice-auto-join-idle-leave.md",
    "content": "# Spec: Voice Channel Auto-Join on Playback & Idle Auto-Leave\n\n**Status:** Implemented  \n**Date:** 2026-04-29  \n**Author:** Justin Hart\n\n---\n\n## Summary\n\nThree related quality-of-life improvements to bot voice channel management, unified under a single `AUTO_JOIN` mode enum:\n\n1. **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.\n2. **Idle auto-leave**: after a configurable period of inactivity (default: 600 seconds), the bot leaves the voice channel. Behavior varies by mode.\n3. **`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.\n\n---\n\n## Motivation\n\nPreviously, 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.\n\nThe 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.\n\n---\n\n## User-Facing Behavior\n\n### `AUTO_JOIN` Modes\n\n| Mode | How bot joins | How bot leaves |\n|---|---|---|\n| `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. |\n| `presence` | Follows users into channels on voice-state updates (existing behavior) | Leaves immediately when last user departs. Idle timeout is **ignored**. |\n| `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. |\n\nThe old `AUTO_JOIN=true` maps to `presence`; `AUTO_JOIN=false` (explicit) maps to `false`; unset now defaults to `play`.\n\n### Auto-Join on Play (in `play` mode)\n\n| Situation | Behavior |\n|---|---|\n| Bot has no voice channel, user clicks a sound | Bot joins the user's current voice channel and plays the sound |\n| User is not in any voice channel | Error: \"Bot is not connected to a voice channel. Use !join in Discord first.\" |\n| Actor is `System` (join/leave sounds) | Same error (no Discord identity to look up) |\n| Bot already in a channel | Plays normally, unchanged |\n| Mode is `presence` or `false` | Error (no auto-join, must use `!join`) |\n\n### Idle Timeout Semantics by Mode\n\n| Event | `play` mode | `presence` mode | `false` mode |\n|---|---|---|---|\n| Bot joins a channel | Timer starts (if timeout > 0) | No timer | No timer |\n| `play_sound` cast | Timer resets | No effect | No effect |\n| Last user leaves | Leave immediately | Leave immediately | Start idle timer (if timeout > 0) |\n| User rejoins (bot alone) | N/A | N/A | Cancel idle timer |\n| Idle timer fires | Leave immediately | Never fires | Leave immediately |\n| Bot leaves (any reason) | Timer cancelled | N/A | Timer cancelled |\n\n---\n\n## Architecture & Design\n\n### `AUTO_JOIN` Enum\n\n`AutoJoinPolicy.mode/0` now returns `:presence | :play | false` (the boolean `false`, not an atom, per Elixir convention). Parsing rules:\n\n| `AUTO_JOIN` value | Mode |\n|---|---|\n| not set | `:play` |\n| `play` | `:play` |\n| `presence`, `true`, `1`, `yes` | `:presence` |\n| `false`, `0`, `no`, any other | `false` |\n\n### Auto-Join Flow (play mode only)\n\n```\nWeb UI / API → AudioPlayer.play_sound(name, %User{discord_id: ...})\n  │\n  ▼  (voice_channel is nil AND mode == :play)\nAudioPlayer.handle_cast({:play_sound, ...}, %{voice_channel: nil})\n  │\n  ├─ extract discord_id from actor\n  │\n  ▼\nVoicePresence.find_user_voice_channel(discord_id)\n  │  searches all cached guilds via GuildCache\n  │\n  ├─ {:ok, {guild_id, channel_id}} ──► Voice.join_channel(guild_id, channel_id)\n  │                                    update state.voice_channel\n  │                                    schedule idle timer (if timeout > 0)\n  │                                    proceed with playback\n  │\n  └─ :not_found ──► Notifier.error(\"Bot is not connected... Use !join in Discord first.\")\n```\n\nThe 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.\n\n### Last-User-Left Routing\n\n`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:\n\n```\nbot_alone_action(guild_id)\n  :presence  ──► VoiceCommands.leave_voice_channel(guild_id)  (existing path)\n  :play      ──► AudioPlayer.last_user_left(guild_id)         (leave immediately)\n  false      ──► AudioPlayer.last_user_left(guild_id)         (start idle timer)\n```\n\n`AudioPlayer.last_user_left/1` is the new single entry point for non-presence leave events. It:\n- `:play` / `:presence`: calls `Voice.leave_channel` directly, clears state.\n- `false`: calls `reset_idle_timeout` — starts the idle timer if `VOICE_IDLE_TIMEOUT_SECONDS > 0`, otherwise no-ops (bot stays indefinitely).\n\n### User-Rejoin Cancel (false mode only)\n\n`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.\n\n### Idle Timeout State Machine\n\n```\nAudioPlayer state: idle_timeout_ref = {timer_ref, token} | nil\n\nplay mode:\n  set_voice_channel({guild, chan}) → cancel old timer → schedule new timer\n  play_sound cast                 → cancel old timer → schedule new timer (reset)\n  set_voice_channel(nil, nil)     → cancel timer\n  last_user_left                  → leave immediately, cancel timer\n  {:idle_timeout, token}          → leave if token matches, else ignore (stale)\n\nfalse mode:\n  last_user_left                  → schedule timer (if timeout > 0)\n  user_joined_channel             → cancel timer\n  {:idle_timeout, token}          → leave if token matches, else ignore (stale)\n\npresence mode:\n  (no timer ever scheduled)\n```\n\nThe `{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.\n\n### `IdleTimeoutPolicy` — Disabled State\n\n`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.\n\n### Direct `Voice.leave_channel` vs `VoiceCommands.leave_voice_channel`\n\n`VoiceCommands.leave_voice_channel` would introduce a circular module dependency:\n- `VoiceCommands` already calls `AudioPlayer.set_voice_channel` (compile-time dep)\n- `AudioPlayer` calling `VoiceCommands` would close the cycle\n\n`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).\n\n### Actor Type Change\n\nPreviously, `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.\n\nBoth 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`.\n\n---\n\n## New Function: `VoicePresence.find_user_voice_channel/1`\n\n```elixir\n@spec find_user_voice_channel(String.t()) ::\n        {:ok, {guild_id :: String.t(), channel_id :: String.t()}} | :not_found\n```\n\nIterates 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.\n\n---\n\n## New Module: `IdleTimeoutPolicy`\n\n```\nlib/soundboard/discord/handler/idle_timeout_policy.ex\n```\n\nReads the `VOICE_IDLE_TIMEOUT_SECONDS` environment variable. Returns the timeout in milliseconds, or `nil` if the value is ≤ 0.\n\n---\n\n## Configuration\n\n| Variable | Default | Description |\n|---|---|---|\n| `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. |\n| `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. |\n\n---\n\n## Database Changes\n\n**None.**\n\n---\n\n## File Inventory\n\n| File | Action |\n|---|---|\n| `lib/soundboard/discord/handler/auto_join_policy.ex` | **Modify** — boolean → enum (`:presence`, `:play`, `false`); remove `enabled?/0` |\n| `lib/soundboard/discord/handler/idle_timeout_policy.ex` | **New** — `VOICE_IDLE_TIMEOUT_SECONDS` config reader; returns `nil` when disabled |\n| `lib/soundboard/discord/handler/voice_presence.ex` | **Modify** — add `find_user_voice_channel/1` |\n| `lib/soundboard/discord/handler/voice_runtime.ex` | **Modify** — mode-aware connect/disconnect routing; `bot_alone_action/1`; `handle_user_rejoin_cancel/1` |\n| `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 |\n| `lib/soundboard_web/live/support/sound_playback.ex` | **Modify** — pass `%User{}` struct instead of username string |\n| `lib/soundboard_web/controllers/api/sound_controller.ex` | **Modify** — pass `%User{}` struct instead of display-name map |\n| `test/soundboard/discord/handler/auto_join_policy_test.exs` | **Rewrite** — enum mode tests; removed `enabled?/0` tests |\n| `test/soundboard/discord/handler/idle_timeout_policy_test.exs` | **New** — covers default, custom value, whitespace, disabled (0 and negative) |\n| `test/soundboard/discord/handler/voice_presence_test.exs` | **New** — covers `find_user_voice_channel/1` |\n| `test/soundboard/discord/handler/voice_runtime_test.exs` | **Modify** — updated mocks to `:presence`; new tests for `play`/`false` mode routing and user-rejoin cancel |\n| `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 |\n| `test/soundboard_web/discord_handler_test.exs` | **Modify** — presence-mode mock; updated leave-sequence assertion |\n| `test/soundboard_web/plugs/api_auth_db_token_test.exs` | **Modify** — actor assertion updated |\n| `test/soundboard_web/controllers/api/sound_controller_test.exs` | **Modify** — actor assertion updated |\n| `test/soundboard_web/live/favorites_live_test.exs` | **Modify** — actor assertion updated |\n\n---\n\n## Testing Strategy\n\n| Layer | What is tested |\n|---|---|\n| `AutoJoinPolicy` | Test env → `:play`. Default (no env var) → `:play`. `play` → `:play`. `presence`/truthy → `:presence`. `false`/falsy/unknown → `false`. |\n| `IdleTimeoutPolicy` | Default → 600,000 ms. Custom value. Whitespace trimming. `0` → `nil`. Negative → `nil`. |\n| `VoicePresence.find_user_voice_channel` | User found in a guild. User not found. User in guild but no channel. Multi-guild search. Cache unavailable. |\n| `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. |\n| `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. |\n| `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. |\n| `AudioPlayer` — `user_joined_channel` | Cancels idle timer. |\n| `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. |\n\n---\n\n## Out of Scope\n\n- **Auto-join for `!play` Discord commands**: the `!play` command handler already requires `!join` first; that flow was not changed.\n- **Per-guild or per-channel idle timeout**: the timeout is global. Future work could make it configurable per guild via DB settings.\n- **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.\n- **Notifying users before leaving**: the bot does not send a Discord message warning that it is about to leave due to inactivity.\n- **`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.\n"
  },
  {
    "path": "docs/specs/youtube-playback.md",
    "content": "# Spec: YouTube Video Playback via Discord Bot Command\n\n**Status:** Draft  \n**Date:** 2026-03-07  \n**Author:** —\n\n---\n\n## Summary\n\nAdd a `!play <youtube_url>` Discord bot command that extracts the audio from a YouTube video and plays it through the bot's current voice channel. This is an ephemeral, on-demand playback — it does **not** save the YouTube audio as a permanent sound in the library.\n\n---\n\n## Motivation\n\nUsers 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.\n\n---\n\n## User-Facing Behavior\n\n### Commands\n\n| Command | Description |\n|---|---|\n| `!play <youtube_url>` | Extract audio from the YouTube URL and play it in the bot's current voice channel. |\n| `!play <youtube_url> <volume>` | Same as above, with an explicit volume (0.0–1.5, default 1.0). |\n| `!stop` | Stop whatever is currently playing (already exists — no change). |\n\n### Responses\n\n| Scenario | Bot Reply |\n|---|---|\n| Success | 🎵 Now playing: `<video_title>` |\n| Bot not in a voice channel | \"I'm not in a voice channel. Use `!join` first.\" |\n| Invalid / unsupported URL | \"That doesn't look like a valid YouTube URL.\" |\n| yt-dlp extraction fails | \"Failed to fetch audio from that URL. It may be private, age-restricted, or region-locked.\" |\n| Already playing (interrupt) | Stops current sound, starts YouTube audio (existing interrupt behavior). |\n\n---\n\n## Architecture & Design\n\n### High-Level Flow\n\n```\nDiscord message \"!play <url>\"\n  │\n  ▼\nCommandHandler.handle_message/1          ← parse command, validate URL format\n  │\n  ▼\nSoundboard.YouTube.Extractor            ← NEW module: call yt-dlp, return audio stream URL + metadata\n  │\n  ▼\nAudioPlayer.play_youtube/3               ← NEW public API on AudioPlayer GenServer\n  │\n  ▼\nPlaybackQueue / PlaybackEngine           ← reuse existing queue & engine (plays URL type)\n  │\n  ▼\nDiscord Voice (EDA)                      ← ffmpeg reads the stream URL, sends RTP\n```\n\n### New Modules\n\n#### 1. `Soundboard.YouTube.Extractor`\n\nWraps the `yt-dlp` CLI directly via `System.cmd/3`.\n\n**Responsibilities:**\n- Validate that a URL is a supported YouTube link via a regex matching `youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`.\n- Extract metadata + stream URL in a **single** `yt-dlp` invocation using combined flags: `yt-dlp --get-title --get-url --get-duration -f bestaudio --no-playlist <url>`.\n- Parse the multi-line stdout (line 1: title, line 2: stream URL, line 3: duration) into a struct.\n- Enforce a maximum duration (configurable, default: 10 minutes / 600s) to prevent abuse.\n- Wrap the call in `Task.async` + `Task.yield/2` with a configurable timeout (default: 15s) to avoid hanging on slow networks or unresponsive URLs.\n- Return `{:ok, %{stream_url: url, title: title, duration_seconds: integer}}` or `{:error, reason}` with user-friendly messages derived from stderr.\n\n**Key design decisions:**\n- Always use the list-of-args form of `System.cmd/3` — never shell interpolation — to prevent command injection.\n- `--no-playlist` flag to prevent accidentally queuing an entire playlist.\n- `-f bestaudio` to get an audio-only stream URL that ffmpeg can consume directly.\n- Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern.\n- Availability check via `System.find_executable(\"yt-dlp\")` or configured path, cached in `persistent_term` on first call.\n- In tests, the module can be mocked via `Mox` or by making the system-cmd call go through a configurable function/module.\n\n**Public API:**\n```elixir\n@spec extract(String.t()) :: {:ok, extraction()} | {:error, String.t()}\n@spec valid_url?(String.t()) :: boolean()\n@spec available?() :: boolean()\n```\n\n### Modified Modules\n\n#### `Soundboard.Discord.Handler.CommandHandler`\n\nAdd a new clause:\n\n```elixir\ndef handle_message(%{content: \"!play \" <> url_and_args} = msg)\n```\n\n- Parse the URL (first token) and optional volume (second token).\n- Validate URL format via `YouTube.Extractor.valid_youtube_url?/1`.\n- Check that the bot is in a voice channel (`AudioPlayer.current_voice_channel/0`).\n- On validation pass, call `AudioPlayer.play_youtube(url, volume, actor)`.\n- Reply with an appropriate Discord message (see table above).\n\n#### `Soundboard.AudioPlayer` (GenServer)\n\nAdd a new public function and cast:\n\n```elixir\ndef play_youtube(url, volume \\\\ 1.0, actor)\n```\n\nInternally sends `{:play_youtube, url, volume, actor}`. The `handle_cast` will:\n1. Call `YouTube.Extractor.extract(url)`.\n2. On success, build a play request (similar to `PlaybackQueue.build_request/3` but using the extracted stream URL and supplied volume directly, bypassing `SoundLibrary`).\n3. Enqueue via `PlaybackQueue.enqueue/3` — reusing all existing interrupt/retry logic.\n\n#### `Soundboard.AudioPlayer.PlaybackEngine`\n\nNo changes expected. The engine already supports `:url` play type, and the extracted stream URL is a direct audio URL that ffmpeg handles natively.\n\n#### `Soundboard.AudioPlayer.PlaybackQueue`\n\nAdd 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`:\n\n```elixir\n@spec build_youtube_request({String.t(), String.t()}, String.t(), number(), term()) ::\n        {:ok, play_request()}\ndef build_youtube_request({guild_id, channel_id}, stream_url, volume, actor)\n```\n\nThe `sound_name` field in the request will be set to the video title (for display in notifications).\n\n### Stats / Tracking\n\nYouTube plays are **not** tracked in the `stats.plays` table (they aren't library sounds). The `PlaybackEngine` already skips tracking for system users — we can use a similar mechanism, or simply not call `track_play_if_needed` for YouTube plays. The `Notifier.sound_played/2` broadcast will still fire so the LiveView shows \"User played <video title>\".\n\n---\n\n## Dependencies\n\n### Hex\n\n**None.** We wrap `yt-dlp` directly via `System.cmd/3` — no third-party Hex packages needed.\n\nWe evaluated `exyt_dlp` (~> 0.1.6) and decided against it. The library is a thin pass-through to `System.cmd(\"yt-dlp\", params)` with no timeout support, no combined-flag calls, and opaque error handling (`:invalid_youtube_url_or_params` for everything). Our own wrapper is ~60 lines, gives us full control, and avoids a low-activity single-maintainer dependency.\n\n### System: `yt-dlp`\n\n`yt-dlp` is a **system dependency** that must be installed on the host.\n\n| Environment | Installation |\n|---|---|\n| Local dev | `brew install yt-dlp` / `pip install yt-dlp` |\n| Docker | Add `RUN pip install yt-dlp` (or grab the static binary) to the Dockerfile |\n\nThe application should gracefully degrade: if `yt-dlp` is not found, `!play` replies with \"YouTube playback is not available (yt-dlp not installed).\" Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern in `PlaybackEngine`.\n\n---\n\n## Configuration\n\nAdd to `config/config.exs` (or runtime config):\n\n```elixir\nconfig :soundboard, :ytdlp_executable, :system          # :system | false | \"/path/to/yt-dlp\"\nconfig :soundboard, :youtube_max_duration_seconds, 600   # 10 minutes\nconfig :soundboard, :ytdlp_timeout_ms, 15_000            # extraction timeout\n```\n\n---\n\n## Database Changes\n\n**None.** YouTube plays are ephemeral and not persisted.\n\n---\n\n## File Inventory (new & changed)\n\n| File | Action |\n|---|---|\n| `lib/soundboard/youtube/extractor.ex` | **New** — yt-dlp wrapper |\n\n| `lib/soundboard/discord/handler/command_handler.ex` | **Modify** — add `!play` clause |\n| `lib/soundboard/audio_player.ex` | **Modify** — add `play_youtube/3` cast |\n| `lib/soundboard/audio_player/playback_queue.ex` | **Modify** — add `build_youtube_request/4` |\n| `Dockerfile` | **Modify** — install `yt-dlp` |\n| `config/config.exs` | **Modify** — add youtube config keys |\n| `test/soundboard/youtube/extractor_test.exs` | **New** — covers extraction + URL validation |\n| `test/soundboard/discord/handler/command_handler_test.exs` | **Modify** — add `!play` tests |\n| `test/soundboard/audio_player_test.exs` | **Modify** — add youtube cast tests |\n\n---\n\n## Security Considerations\n\n- **Input sanitization:** The YouTube URL is passed as an argument to `System.cmd/3`. Use the list-of-args form (`System.cmd(\"yt-dlp\", [args...])`) — never shell interpolation — to prevent command injection.\n- **Duration cap:** Enforce the max duration to prevent a user from streaming a 10-hour video and monopolizing the voice channel.\n- **Rate limiting:** (Future / optional) Consider a per-user cooldown on `!play` to prevent spam. Not in scope for v1 but worth noting.\n- **No disk writes:** The stream URL approach means no temp files accumulate on the server.\n\n---\n\n## Testing Strategy\n\n| Layer | What to test |\n|---|---|\n| `YouTube.Extractor` | URL validation (valid/invalid/edge cases: `watch?v=`, `youtu.be/`, shorts, playlist URLs rejected, non-YouTube rejected). Mock `System.cmd` to test parse logic for yt-dlp stdout. Timeout handling. Duration enforcement. Missing binary. |\n| `CommandHandler` | `!play` with valid URL dispatches to `AudioPlayer`. `!play` with garbage URL returns error message. `!play` with no args returns usage hint. Bot not in channel returns error. |\n| `AudioPlayer` | `play_youtube` cast flows through to `PlaybackQueue`. Integration with mock voice. |\n| Manual / integration | End-to-end: bot in voice → `!play https://youtu.be/dQw4w9WgXcQ` → audio plays in Discord. |\n\n---\n\n## Out of Scope (future enhancements)\n\n- Saving a YouTube sound to the library permanently (\"!save\" command).\n- Queue / playlist support (multiple `!play` commands queued in order).\n- Playback controls (`!pause`, `!resume`, `!skip`).\n- Playing from other platforms (SoundCloud, Spotify, etc.).\n- Web UI integration (play YouTube from the LiveView).\n- Now-playing status / progress indicator in Discord or the web UI.\n\n---\n\n## Open Questions\n\n1. **Should `!play` also accept non-YouTube URLs?** yt-dlp supports hundreds of sites. We could allow any yt-dlp-supported URL, or restrict to YouTube only for v1. Restricting is simpler and safer — recommend YouTube-only for now.\n2. **Should the extraction happen in the `CommandHandler` (before casting to `AudioPlayer`) or inside the `AudioPlayer` GenServer?** Doing it in a Task spawned by `CommandHandler` keeps the AudioPlayer GenServer responsive. However, the current flow already uses `Task.async` inside `PlaybackQueue.start_playback/2`, so doing extraction inside the AudioPlayer cast is consistent. **Recommendation:** Extract in the AudioPlayer cast (inside the spawned playback Task) so the command handler remains fast and the reply can be sent immediately (\"⏳ Fetching audio…\").\n3. **Max duration default?** 10 minutes seems reasonable. Should this be configurable per-guild or global? **Recommendation:** Global config for v1.\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\n\n# Run migrations\necho \"Running database migrations...\"\nmix ecto.migrate\n\n# Start Phoenix server in foreground\n# Using exec ensures proper signal handling and process management\necho \"Starting Phoenix server...\"\nexec mix phx.server\n"
  },
  {
    "path": "lib/soundboard/accounts/api_token.ex",
    "content": "defmodule Soundboard.Accounts.ApiToken do\n  @moduledoc \"\"\"\n  API access token bound to a user.\n\n  The token hash is used for verification. The plaintext token is also persisted\n  so the Settings UI can display and copy active tokens after creation.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n  alias Soundboard.Accounts.User\n\n  @type t :: %__MODULE__{\n          id: integer() | nil,\n          user_id: integer() | nil,\n          user: User.t() | Ecto.Association.NotLoaded.t() | nil,\n          token_hash: String.t() | nil,\n          token: String.t() | nil,\n          label: String.t() | nil,\n          revoked_at: NaiveDateTime.t() | nil,\n          last_used_at: NaiveDateTime.t() | nil,\n          inserted_at: NaiveDateTime.t() | nil,\n          updated_at: NaiveDateTime.t() | nil\n        }\n\n  schema \"api_tokens\" do\n    belongs_to :user, User\n    field :token_hash, :string\n    field :token, :string\n    field :label, :string\n    field :revoked_at, :naive_datetime\n    field :last_used_at, :naive_datetime\n\n    timestamps()\n  end\n\n  def changeset(token, attrs) do\n    token\n    |> cast(attrs, [:user_id, :token_hash, :token, :label, :revoked_at, :last_used_at])\n    |> validate_required([:user_id, :token_hash])\n    |> unique_constraint(:token_hash)\n    |> assoc_constraint(:user)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/accounts/api_tokens.ex",
    "content": "defmodule Soundboard.Accounts.ApiTokens do\n  @moduledoc \"\"\"\n  Context for managing API tokens bound to users.\n  \"\"\"\n  require Logger\n\n  import Ecto.Query\n  alias Soundboard.Accounts.{ApiToken, User}\n  alias Soundboard.Repo\n\n  @type verify_error :: :invalid | :token_update_failed\n  @type verify_result :: {:ok, User.t(), ApiToken.t()} | {:error, verify_error}\n  @type revoke_result ::\n          {:ok, ApiToken.t()} | {:error, :forbidden | :not_found | Ecto.Changeset.t()}\n\n  @prefix \"sb_\"\n\n  @spec list_tokens(User.t()) :: [ApiToken.t()]\n  def list_tokens(%User{id: user_id}) do\n    from(t in ApiToken,\n      where: t.user_id == ^user_id and is_nil(t.revoked_at),\n      order_by: [desc: t.inserted_at]\n    )\n    |> Repo.all()\n  end\n\n  @spec generate_token(User.t(), map()) ::\n          {:ok, String.t(), ApiToken.t()} | {:error, Ecto.Changeset.t()}\n  def generate_token(%User{id: user_id}, attrs \\\\ %{}) do\n    raw = random_token()\n    hash = hash_token(raw)\n\n    changeset =\n      %ApiToken{}\n      |> ApiToken.changeset(%{\n        user_id: user_id,\n        token_hash: hash,\n        token: raw,\n        label: Map.get(attrs, \"label\") || Map.get(attrs, :label)\n      })\n\n    case Repo.insert(changeset) do\n      {:ok, token} -> {:ok, raw, token}\n      {:error, changeset} -> {:error, changeset}\n    end\n  end\n\n  @spec verify_token(String.t()) :: verify_result()\n  def verify_token(raw) when is_binary(raw) do\n    query =\n      from t in ApiToken,\n        where: t.token_hash == ^hash_token(raw) and is_nil(t.revoked_at)\n\n    case Repo.one(query) do\n      nil ->\n        {:error, :invalid}\n\n      token ->\n        token = Repo.preload(token, :user)\n\n        case update_last_used_at(token) do\n          {:ok, _updated_token} ->\n            {:ok, token.user, token}\n\n          {:error, changeset} ->\n            Logger.error(\"Failed to update API token last_used_at: #{inspect(changeset.errors)}\")\n            {:error, :token_update_failed}\n        end\n    end\n  end\n\n  @spec revoke_token(User.t(), integer() | String.t()) :: revoke_result()\n  def revoke_token(%User{id: user_id}, token_id) do\n    token_id = normalize_id(token_id)\n\n    case Repo.get(ApiToken, token_id) do\n      %ApiToken{user_id: ^user_id} = token ->\n        token\n        |> Ecto.Changeset.change(\n          revoked_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n        )\n        |> Repo.update()\n\n      %ApiToken{} ->\n        {:error, :forbidden}\n\n      nil ->\n        {:error, :not_found}\n    end\n  end\n\n  defp update_last_used_at(%ApiToken{} = token) do\n    token\n    |> Ecto.Changeset.change(\n      last_used_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n    )\n    |> Repo.update()\n  end\n\n  defp random_token do\n    @prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)\n  end\n\n  defp hash_token(raw) do\n    :crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)\n  end\n\n  defp normalize_id(id) when is_integer(id), do: id\n\n  defp normalize_id(id) when is_binary(id) do\n    case Integer.parse(id) do\n      {int, \"\"} -> int\n      _ -> -1\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/accounts/user.ex",
    "content": "defmodule Soundboard.Accounts.User do\n  @moduledoc \"\"\"\n  The User module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n\n  @type t :: %__MODULE__{\n          id: integer() | nil,\n          discord_id: String.t() | nil,\n          username: String.t() | nil,\n          avatar: String.t() | nil,\n          inserted_at: NaiveDateTime.t() | nil,\n          updated_at: NaiveDateTime.t() | nil\n        }\n\n  schema \"users\" do\n    field :discord_id, :string\n    field :username, :string\n    field :avatar, :string\n\n    timestamps()\n  end\n\n  def changeset(user, attrs) do\n    user\n    |> cast(attrs, [:discord_id, :username, :avatar])\n    |> validate_required([:discord_id, :username])\n    |> unique_constraint(:discord_id)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/accounts.ex",
    "content": "defmodule Soundboard.Accounts do\n  @moduledoc \"\"\"\n  Accounts boundary helpers used by web and runtime code.\n  \"\"\"\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Repo\n  import Ecto.Query\n\n  def get_user(user_id), do: Repo.get(User, user_id)\n\n  def avatars_by_usernames([]), do: %{}\n\n  def avatars_by_usernames(usernames) when is_list(usernames) do\n    from(u in User, where: u.username in ^usernames, select: {u.username, u.avatar})\n    |> Repo.all()\n    |> Map.new()\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/application.ex",
    "content": "defmodule Soundboard.Application do\n  # See https://hexdocs.pm/elixir/Application.html\n  # for more information on OTP Applications\n  @moduledoc false\n\n  use Application\n  alias Soundboard.Discord.RuntimeCapability\n  require Logger\n\n  @impl true\n  def start(_type, _args) do\n    Logger.info(\"Starting Soundboard Application\")\n\n    children = [\n      Soundboard.Repo,\n      {Soundboard.AudioPlayer, []},\n      SoundboardWeb.Telemetry,\n      {Phoenix.PubSub, name: Soundboard.PubSub},\n      SoundboardWeb.Presence,\n      SoundboardWeb.PresenceHandler,\n      Soundboard.Discord.Handler.State,\n      SoundboardWeb.Endpoint\n      | discord_children()\n    ]\n\n    opts = [strategy: :one_for_one, name: Soundboard.Supervisor]\n    Supervisor.start_link(children, opts)\n  end\n\n  defp discord_children do\n    if RuntimeCapability.discord_handler_enabled?() do\n      [Soundboard.Discord.Handler]\n    else\n      RuntimeCapability.log_degraded_mode()\n      []\n    end\n  end\n\n  # Tell Phoenix to update the endpoint configuration\n  # whenever the application is updated.\n  @impl true\n  def config_change(changed, _new, removed) do\n    SoundboardWeb.Endpoint.config_change(changed, removed)\n    :ok\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player/notifier.ex",
    "content": "defmodule Soundboard.AudioPlayer.Notifier do\n  @moduledoc false\n\n  alias Soundboard.PubSubTopics\n\n  def sound_played(sound_name, actor_name) do\n    PubSubTopics.broadcast_sound_played(sound_name, actor_name)\n  end\n\n  def error(message) do\n    PubSubTopics.broadcast_error(message)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player/playback_engine.ex",
    "content": "defmodule Soundboard.AudioPlayer.PlaybackEngine do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{AudioPlayer, AudioPlayer.Notifier, AudioPlayer.SoundLibrary, Discord.Voice}\n\n  @system_users [\"System\"]\n  @rtp_probe_poll_ms 20\n  @rtp_probe_default_timeout_ms 6_000\n  @voice_not_ready_retry_ms 350\n  @voice_ready_poll_ms 100\n  @voice_ready_timeout_ms 4_000\n  @voice_ready_fast_timeout_ms 1_200\n  @voice_settle_ms 120\n  @rejoin_retry_threshold 3\n  @max_play_attempts 20\n\n  def play(guild_id, channel_id, sound_name, path_or_url, volume, actor) do\n    join_state = ensure_joined_channel(guild_id, channel_id)\n    maybe_settle_before_play(join_state)\n    submit_play_request(guild_id, sound_name, path_or_url, volume, actor)\n  end\n\n  defp maybe_settle_before_play({:joined, :ok}) do\n    Process.sleep(@voice_settle_ms)\n  end\n\n  defp maybe_settle_before_play(_), do: :ok\n\n  defp submit_play_request(guild_id, sound_name, path_or_url, volume, actor) do\n    if is_nil(ffmpeg_executable()) do\n      Logger.error(\"ffmpeg not found in PATH. Cannot play #{sound_name}\")\n      broadcast_error(\"ffmpeg is not installed on this host\")\n      :error\n    else\n      {play_input, play_type} = SoundLibrary.prepare_play_input(sound_name, path_or_url)\n\n      play_request = %{\n        guild_id: guild_id,\n        play_input: play_input,\n        play_type: play_type,\n        play_options: [volume: clamp_volume(volume)],\n        sound_name: sound_name,\n        actor: actor\n      }\n\n      play_with_retries(play_request, 0, false)\n    end\n  end\n\n  defp play_with_retries(play_request, attempt, refresh_attempted)\n       when attempt < @max_play_attempts do\n    case voice_play(play_request) |> classify_play_attempt() do\n      :ok ->\n        maybe_probe_first_rtp(play_request.guild_id, play_request.sound_name, attempt + 1)\n        track_play_if_needed(play_request.sound_name, play_request.actor)\n        broadcast_success(play_request.sound_name, play_request.actor)\n        :ok\n\n      {:retry, retry} ->\n        retry_play_attempt(play_request, attempt, refresh_attempted, retry)\n\n      {:error, reason} ->\n        Logger.error(\"Voice.play failed: #{inspect(reason)} (attempt #{attempt + 1})\")\n        broadcast_error(\"Failed to play sound: #{reason}\")\n        :error\n    end\n  end\n\n  defp play_with_retries(%{sound_name: sound_name}, attempt, _refresh_attempted) do\n    Logger.error(\"Exceeded max retries (#{attempt}) for playing #{sound_name}\")\n    broadcast_error(\"Failed to play sound after multiple attempts\")\n    :error\n  end\n\n  defp voice_play(play_request) do\n    Voice.play(\n      play_request.guild_id,\n      play_request.play_input,\n      play_request.play_type,\n      play_request.play_options\n    )\n  end\n\n  defp classify_play_attempt(:ok), do: :ok\n\n  defp classify_play_attempt({:error, \"Audio already playing in voice channel.\"}) do\n    {:retry,\n     %{\n       log: \"Audio still playing, stopping and retrying...\",\n       sleep_ms: 50,\n       stop_first?: true,\n       force_refresh?: false\n     }}\n  end\n\n  defp classify_play_attempt({:error, \"Must be connected to voice channel to play audio.\"}) do\n    {:retry,\n     %{\n       log: \"Voice reported not connected, waiting before retry...\",\n       sleep_ms: @voice_not_ready_retry_ms,\n       stop_first?: false,\n       force_refresh?: false\n     }}\n  end\n\n  defp classify_play_attempt({:error, \"Voice session is still negotiating encryption.\"}) do\n    {:retry,\n     %{\n       log:\n         \"Voice encryption not ready yet, waiting #{@voice_not_ready_retry_ms}ms before retry...\",\n       sleep_ms: @voice_not_ready_retry_ms,\n       stop_first?: false,\n       force_refresh?: true\n     }}\n  end\n\n  defp classify_play_attempt({:error, reason}), do: {:error, reason}\n  defp classify_play_attempt(other), do: {:error, inspect(other)}\n\n  defp retry_play_attempt(play_request, attempt, refresh_attempted, retry) do\n    Logger.warning(\"#{retry.log} (attempt #{attempt + 1})\")\n\n    if retry.stop_first? do\n      Voice.stop(play_request.guild_id)\n    end\n\n    refresh_attempted =\n      maybe_trigger_rejoin(\n        play_request.guild_id,\n        attempt,\n        refresh_attempted,\n        retry.force_refresh?\n      )\n\n    Process.sleep(retry.sleep_ms)\n    play_with_retries(play_request, attempt + 1, refresh_attempted)\n  end\n\n  defp maybe_trigger_rejoin(guild_id, attempt, refresh_attempted, force_refresh) do\n    if attempt >= @rejoin_retry_threshold and not refresh_attempted do\n      maybe_rejoin_current_channel(guild_id, force_refresh)\n      true\n    else\n      refresh_attempted\n    end\n  end\n\n  defp maybe_rejoin_current_channel(guild_id, force_refresh) do\n    case AudioPlayer.current_voice_channel() do\n      {:ok, {^guild_id, channel_id}} ->\n        maybe_rejoin_for_channel(guild_id, channel_id, force_refresh)\n\n      {:ok, _other_channel} ->\n        :ok\n\n      {:error, reason} ->\n        Logger.debug(\"Skipping rejoin lookup for guild #{guild_id}: #{inspect(reason)}\")\n        :ok\n    end\n\n    :ok\n  end\n\n  defp maybe_rejoin_for_channel(guild_id, channel_id, true) do\n    joined? = Voice.channel_id(guild_id) == to_string(channel_id)\n    ready? = match?({:ok, true}, safe_voice_ready(guild_id))\n\n    cond do\n      joined? and not ready? ->\n        refresh_voice_session(guild_id, channel_id)\n\n      joined? and ready? ->\n        Logger.debug(\"Skipping refresh; voice already ready in channel #{channel_id}\")\n\n      true ->\n        rejoin_voice_channel(guild_id, channel_id)\n    end\n  end\n\n  defp maybe_rejoin_for_channel(guild_id, channel_id, false) do\n    joined? = Voice.channel_id(guild_id) == to_string(channel_id)\n    ready? = match?({:ok, true}, safe_voice_ready(guild_id))\n\n    if joined? and ready? do\n      Logger.debug(\"Skipping rejoin; already in voice channel #{channel_id}\")\n    else\n      rejoin_voice_channel(guild_id, channel_id)\n    end\n  end\n\n  defp refresh_voice_session(guild_id, channel_id) do\n    reconnect_voice_session(\n      guild_id,\n      channel_id,\n      \"Refreshing voice session in channel #{channel_id} with in-place rejoin\"\n    )\n  end\n\n  defp rejoin_voice_channel(guild_id, channel_id) do\n    reconnect_voice_session(guild_id, channel_id, \"Rejoining voice channel #{channel_id}\")\n  end\n\n  defp reconnect_voice_session(guild_id, channel_id, log_message) do\n    Logger.info(log_message)\n    Voice.join_channel(guild_id, channel_id)\n    wait_for_voice_ready(guild_id)\n  end\n\n  defp ensure_joined_channel(guild_id, channel_id) do\n    if Voice.channel_id(guild_id) == to_string(channel_id) do\n      {:already_joined, wait_for_voice_ready(guild_id, @voice_ready_fast_timeout_ms)}\n    else\n      Logger.info(\"Joining voice channel #{channel_id}\")\n      Voice.join_channel(guild_id, channel_id)\n      Process.sleep(150)\n      {:joined, wait_for_voice_ready(guild_id)}\n    end\n  end\n\n  defp maybe_probe_first_rtp(guild_id, sound_name, attempt_number) do\n    if Application.get_env(:soundboard, :voice_rtp_probe, false) do\n      timeout_ms =\n        Application.get_env(\n          :soundboard,\n          :voice_rtp_probe_timeout_ms,\n          @rtp_probe_default_timeout_ms\n        )\n\n      initial_seq = current_rtp_sequence(guild_id)\n      started_at = System.monotonic_time(:millisecond)\n\n      Task.start(fn ->\n        wait_for_first_rtp(\n          guild_id,\n          sound_name,\n          attempt_number,\n          initial_seq,\n          started_at,\n          timeout_ms\n        )\n      end)\n    end\n\n    :ok\n  end\n\n  defp wait_for_first_rtp(\n         guild_id,\n         sound_name,\n         attempt_number,\n         initial_seq,\n         started_at,\n         timeout_ms\n       ) do\n    elapsed_ms = System.monotonic_time(:millisecond) - started_at\n    current_seq = current_rtp_sequence(guild_id)\n    initial_seq_value = unwrap_sequence(initial_seq)\n    current_seq_value = unwrap_sequence(current_seq)\n\n    cond do\n      is_integer(initial_seq_value) and is_integer(current_seq_value) and\n          current_seq_value != initial_seq_value ->\n        Logger.info(\n          \"RTP probe: first packet for #{sound_name} after #{elapsed_ms}ms \" <>\n            \"(attempt #{attempt_number}, seq #{initial_seq_value} -> #{current_seq_value})\"\n        )\n\n      is_nil(initial_seq_value) and is_integer(current_seq_value) ->\n        Logger.info(\n          \"RTP probe: sequence initialized for #{sound_name} after #{elapsed_ms}ms \" <>\n            \"(attempt #{attempt_number}, seq #{current_seq_value})\"\n        )\n\n      elapsed_ms >= timeout_ms ->\n        status = safe_voice_status(guild_id)\n\n        Logger.warning(\n          \"RTP probe: no progress for #{sound_name} within #{timeout_ms}ms \" <>\n            \"(attempt #{attempt_number}, initial_seq=#{inspect(initial_seq)}, \" <>\n            \"current_seq=#{inspect(current_seq)}, channel=#{inspect(status.channel)}, \" <>\n            \"playing=#{inspect(status.playing)})\"\n        )\n\n      true ->\n        Process.sleep(@rtp_probe_poll_ms)\n\n        wait_for_first_rtp(\n          guild_id,\n          sound_name,\n          attempt_number,\n          initial_seq,\n          started_at,\n          timeout_ms\n        )\n    end\n  end\n\n  defp wait_for_voice_ready(guild_id, timeout_ms \\\\ @voice_ready_timeout_ms) do\n    started_at = System.monotonic_time(:millisecond)\n    do_wait_for_voice_ready(guild_id, started_at, timeout_ms)\n  end\n\n  defp do_wait_for_voice_ready(guild_id, started_at, timeout_ms) do\n    cond do\n      match?({:ok, true}, safe_voice_ready(guild_id)) ->\n        :ok\n\n      System.monotonic_time(:millisecond) - started_at >= timeout_ms ->\n        Logger.warning(\n          \"Timed out waiting for voice readiness in guild #{guild_id} \" <>\n            \"(channel=#{inspect(safe_voice_channel(guild_id))})\"\n        )\n\n        :timeout\n\n      true ->\n        Process.sleep(@voice_ready_poll_ms)\n        do_wait_for_voice_ready(guild_id, started_at, timeout_ms)\n    end\n  end\n\n  defp current_rtp_sequence(guild_id) do\n    case Voice.get_voice(guild_id) do\n      {:ok, %{rtp_sequence: seq}} when is_integer(seq) -> {:ok, seq}\n      {:ok, _state} -> {:ok, nil}\n      {:error, reason} -> {:error, {:voice_state_unavailable, reason}}\n    end\n  rescue\n    error -> {:error, {:voice_state_unavailable, Exception.message(error)}}\n  end\n\n  defp safe_voice_status(guild_id) do\n    %{\n      channel: safe_voice_channel(guild_id),\n      playing: safe_voice_playing(guild_id)\n    }\n  end\n\n  defp safe_voice_ready(guild_id) do\n    {:ok, Voice.ready?(guild_id)}\n  rescue\n    error -> {:error, {:voice_not_ready, Exception.message(error)}}\n  end\n\n  defp safe_voice_channel(guild_id) do\n    {:ok, Voice.channel_id(guild_id)}\n  rescue\n    error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}\n  end\n\n  defp safe_voice_playing(guild_id) do\n    {:ok, Voice.playing?(guild_id)}\n  rescue\n    error -> {:error, {:voice_playback_unavailable, Exception.message(error)}}\n  end\n\n  defp track_play_if_needed(sound_name, actor) do\n    cond do\n      system_user?(actor) ->\n        :ok\n\n      is_integer(actor_user_id(actor)) ->\n        Soundboard.Stats.track_play(sound_name, actor_user_id(actor))\n\n      is_binary(actor_display_name(actor)) ->\n        username = actor_display_name(actor)\n\n        case Soundboard.Repo.get_by(User, username: username) do\n          %{id: user_id} -> Soundboard.Stats.track_play(sound_name, user_id)\n          nil -> Logger.warning(\"Could not find user_id for #{username}\")\n        end\n\n      true ->\n        Logger.warning(\"Could not determine playback actor for #{sound_name}\")\n    end\n  end\n\n  defp broadcast_success(sound_name, actor) do\n    Notifier.sound_played(sound_name, actor_display_name(actor) || \"Unknown\")\n  end\n\n  defp broadcast_error(message) do\n    Notifier.error(message)\n  end\n\n  defp unwrap_sequence({:ok, sequence}), do: sequence\n  defp unwrap_sequence({:error, _reason}), do: nil\n\n  defp ffmpeg_executable do\n    case Application.get_env(:soundboard, :ffmpeg_executable, :system) do\n      :system -> System.find_executable(\"ffmpeg\")\n      false -> nil\n      path when is_binary(path) -> path\n    end\n  end\n\n  defp clamp_volume(value) when is_number(value) do\n    value\n    |> max(0.0)\n    |> min(1.5)\n    |> Float.round(4)\n  end\n\n  defp clamp_volume(_), do: 1.0\n\n  defp actor_display_name(%{display_name: display_name}) when is_binary(display_name),\n    do: display_name\n\n  defp actor_display_name(%User{username: username}) when is_binary(username), do: username\n  defp actor_display_name(username) when is_binary(username), do: username\n  defp actor_display_name(_), do: nil\n\n  defp actor_user_id(%{user_id: user_id}) when is_integer(user_id), do: user_id\n  defp actor_user_id(%User{id: user_id}) when is_integer(user_id), do: user_id\n  defp actor_user_id(_), do: nil\n\n  defp system_user?(actor), do: actor_display_name(actor) in @system_users\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player/playback_queue.ex",
    "content": "defmodule Soundboard.AudioPlayer.PlaybackQueue do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer.{PlaybackEngine, SoundLibrary, State}\n  alias Soundboard.Discord.Voice\n\n  @type play_request :: %{\n          guild_id: String.t(),\n          channel_id: String.t(),\n          sound_name: String.t(),\n          path_or_url: String.t(),\n          volume: number(),\n          actor: term()\n        }\n\n  @spec build_request({String.t(), String.t()}, String.t(), term()) ::\n          {:ok, play_request()} | {:error, String.t()}\n  def build_request({guild_id, channel_id}, sound_name, actor) do\n    case SoundLibrary.get_sound_path(sound_name) do\n      {:ok, {path_or_url, volume}} ->\n        {:ok,\n         %{\n           guild_id: guild_id,\n           channel_id: channel_id,\n           sound_name: sound_name,\n           path_or_url: path_or_url,\n           volume: volume,\n           actor: actor\n         }}\n\n      {:error, reason} ->\n        {:error, reason}\n    end\n  end\n\n  @spec enqueue(State.t(), play_request(), pos_integer()) :: State.t()\n  def enqueue(%State{} = state, request, interrupt_watchdog_ms) do\n    case state.current_playback do\n      nil ->\n        state\n        |> cancel_interrupt_watchdog()\n        |> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})\n        |> start_playback(request)\n\n      _ ->\n        state\n        |> Map.put(:pending_request, request)\n        |> maybe_interrupt_current(interrupt_watchdog_ms)\n    end\n  end\n\n  @spec clear_all(State.t()) :: State.t()\n  def clear_all(%State{} = state) do\n    state\n    |> clear_current_playback()\n    |> Map.merge(%{\n      pending_request: nil,\n      interrupting: false,\n      interrupt_watchdog_attempt: 0\n    })\n  end\n\n  @spec handle_task_result(State.t(), term()) :: State.t()\n  def handle_task_result(\n        %State{current_playback: %{sound_name: sound_name} = current} = state,\n        result\n      ) do\n    case result do\n      :ok ->\n        %{\n          state\n          | current_playback:\n              current\n              |> Map.put(:task_ref, nil)\n              |> Map.put(:task_pid, nil)\n        }\n\n      :error ->\n        Logger.error(\"Playback start failed for #{sound_name}\")\n        state |> clear_current_playback() |> maybe_start_pending()\n    end\n  end\n\n  @spec handle_task_down(State.t(), term()) :: State.t()\n  def handle_task_down(%State{} = state, reason) do\n    Logger.error(\"Playback task crashed: #{inspect(reason)}\")\n    state |> clear_current_playback() |> maybe_start_pending()\n  end\n\n  @spec handle_interrupt_watchdog(\n          State.t(),\n          String.t(),\n          non_neg_integer(),\n          pos_integer(),\n          pos_integer()\n        ) ::\n          State.t()\n  def handle_interrupt_watchdog(\n        %State{interrupting: true, interrupt_watchdog_attempt: attempt} = state,\n        guild_id,\n        attempt,\n        max_attempts,\n        interrupt_watchdog_ms\n      ) do\n    cond do\n      state.current_playback == nil ->\n        state |> reset_interrupt_state() |> maybe_start_pending()\n\n      attempt >= max_attempts ->\n        Logger.warning(\n          \"Interrupt watchdog timed out for guild #{guild_id}; forcing latest request\"\n        )\n\n        Voice.stop(guild_id)\n        state |> clear_current_playback() |> maybe_start_pending()\n\n      match?({:ok, true}, safe_voice_playing(guild_id)) ->\n        Logger.debug(\n          \"Interrupt watchdog: audio still playing in guild #{guild_id}, retrying stop\"\n        )\n\n        Voice.stop(guild_id)\n        schedule_interrupt_watchdog(state, guild_id, attempt + 1, interrupt_watchdog_ms)\n\n      true ->\n        Logger.debug(\"Interrupt watchdog: playback already stopped for guild #{guild_id}\")\n        state |> clear_current_playback() |> maybe_start_pending()\n    end\n  end\n\n  def handle_interrupt_watchdog(%State{} = state, _guild_id, _attempt, _max_attempts, _delay_ms),\n    do: state\n\n  @spec handle_playback_finished(State.t(), String.t()) :: State.t()\n  def handle_playback_finished(%State{} = state, guild_id) do\n    cond do\n      match?(%{guild_id: ^guild_id}, state.current_playback) ->\n        state\n        |> clear_current_playback()\n        |> maybe_start_pending()\n\n      state.interrupting and match?({^guild_id, _}, state.voice_channel) ->\n        state\n        |> reset_interrupt_state()\n        |> maybe_start_pending()\n\n      true ->\n        state\n    end\n  end\n\n  defp start_playback(state, request) do\n    task =\n      Task.async(fn ->\n        PlaybackEngine.play(\n          request.guild_id,\n          request.channel_id,\n          request.sound_name,\n          request.path_or_url,\n          request.volume,\n          request.actor\n        )\n      end)\n\n    %{\n      state\n      | current_playback: request |> Map.put(:task_ref, task.ref) |> Map.put(:task_pid, task.pid)\n    }\n  end\n\n  defp maybe_interrupt_current(%State{current_playback: %{guild_id: guild_id}} = state, delay_ms) do\n    Logger.debug(\"Interrupting current playback in guild #{guild_id} for latest request\")\n    Voice.stop(guild_id)\n\n    if match?({:ok, true}, safe_voice_playing(guild_id)) do\n      state\n      |> Map.put(:interrupting, true)\n      |> schedule_interrupt_watchdog(guild_id, 1, delay_ms)\n    else\n      Logger.debug(\"Interrupt fast-path: playback stopped immediately in guild #{guild_id}\")\n\n      state\n      |> clear_current_playback()\n      |> maybe_start_pending()\n    end\n  end\n\n  defp maybe_interrupt_current(%State{} = state, _delay_ms), do: state\n\n  defp maybe_start_pending(%State{pending_request: nil} = state), do: state\n\n  defp maybe_start_pending(%State{} = state) do\n    request = state.pending_request\n\n    case state.voice_channel do\n      {guild_id, channel_id}\n      when guild_id == request.guild_id and channel_id == request.channel_id ->\n        state\n        |> Map.put(:pending_request, nil)\n        |> start_playback(request)\n\n      _ ->\n        %{state | pending_request: nil}\n    end\n  end\n\n  defp clear_current_playback(%State{} = state) do\n    cancel_playback_task(state.current_playback)\n\n    state\n    |> cancel_interrupt_watchdog()\n    |> Map.merge(%{\n      current_playback: nil,\n      interrupting: false,\n      interrupt_watchdog_attempt: 0\n    })\n  end\n\n  defp reset_interrupt_state(%State{} = state) do\n    state\n    |> cancel_interrupt_watchdog()\n    |> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})\n  end\n\n  defp schedule_interrupt_watchdog(%State{} = state, guild_id, attempt, delay_ms) do\n    state = cancel_interrupt_watchdog(state)\n\n    ref = Process.send_after(self(), {:interrupt_watchdog, guild_id, attempt}, delay_ms)\n\n    %{state | interrupt_watchdog_ref: ref, interrupt_watchdog_attempt: attempt}\n  end\n\n  defp cancel_interrupt_watchdog(%State{interrupt_watchdog_ref: nil} = state), do: state\n\n  defp cancel_interrupt_watchdog(%State{} = state) do\n    Process.cancel_timer(state.interrupt_watchdog_ref)\n    %{state | interrupt_watchdog_ref: nil}\n  end\n\n  defp cancel_playback_task(nil), do: :ok\n\n  defp cancel_playback_task(%{task_pid: pid, task_ref: ref}) when is_pid(pid) do\n    if is_reference(ref), do: Process.demonitor(ref, [:flush])\n\n    if Process.alive?(pid) do\n      Process.exit(pid, :kill)\n    end\n\n    :ok\n  end\n\n  defp cancel_playback_task(_), do: :ok\n\n  defp safe_voice_playing(guild_id) do\n    {:ok, Voice.playing?(guild_id)}\n  rescue\n    error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player/sound_library.ex",
    "content": "defmodule Soundboard.AudioPlayer.SoundLibrary do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.Sound\n\n  def ensure_cache do\n    case :ets.info(:sound_meta_cache) do\n      :undefined ->\n        :ets.new(:sound_meta_cache, [:set, :named_table, :public, read_concurrency: true])\n        :ok\n\n      _ ->\n        :ok\n    end\n  end\n\n  def get_sound_path(sound_name) do\n    ensure_cache()\n\n    case lookup_cached_sound(sound_name) do\n      {:hit, {_type, input, volume}} -> {:ok, {input, volume}}\n      :miss -> resolve_and_cache_sound(sound_name)\n    end\n  end\n\n  def prepare_play_input(sound_name, path_or_url) do\n    ensure_cache()\n\n    case :ets.lookup(:sound_meta_cache, sound_name) do\n      [{^sound_name, %{source_type: source_type}}] when source_type in [\"url\", \"local\"] ->\n        {path_or_url, :url}\n\n      _ ->\n        case Soundboard.Repo.get_by(Sound, filename: sound_name) do\n          %{source_type: source_type} when source_type in [\"url\", \"local\"] ->\n            {path_or_url, :url}\n\n          _ ->\n            Logger.warning(\"Unknown source type for #{sound_name}; defaulting to direct playback\")\n            {path_or_url, :url}\n        end\n    end\n  end\n\n  @doc \"\"\"\n  Removes any cached metadata for the given `sound_name` so future plays use fresh data.\n  \"\"\"\n  def invalidate_cache(sound_name) when is_binary(sound_name) do\n    ensure_cache()\n    :ets.delete(:sound_meta_cache, sound_name)\n    :ok\n  end\n\n  def invalidate_cache(_), do: :ok\n\n  defp lookup_cached_sound(sound_name) do\n    case :ets.lookup(:sound_meta_cache, sound_name) do\n      [{^sound_name, %{source_type: source, input: input, volume: volume}}] ->\n        {:hit, {source, input, volume}}\n\n      _ ->\n        :miss\n    end\n  end\n\n  defp resolve_and_cache_sound(sound_name) do\n    case Soundboard.Repo.get_by(Sound, filename: sound_name) do\n      nil ->\n        Logger.error(\"Sound not found in database: #{sound_name}\")\n        {:error, \"Sound not found\"}\n\n      %{source_type: \"url\", url: url, volume: volume} when is_binary(url) ->\n        meta = %{source_type: \"url\", input: url, volume: volume || 1.0}\n        cache_sound(sound_name, meta)\n        {:ok, {meta.input, meta.volume}}\n\n      %{source_type: \"local\", filename: filename, volume: volume} when is_binary(filename) ->\n        path = resolve_upload_path(filename)\n\n        if File.exists?(path) do\n          meta = %{source_type: \"local\", input: path, volume: volume || 1.0}\n          cache_sound(sound_name, meta)\n          {:ok, {meta.input, meta.volume}}\n        else\n          Logger.error(\"Local file not found: #{path}\")\n          {:error, \"Sound file not found at #{path}\"}\n        end\n\n      _sound ->\n        Logger.error(\"Invalid sound configuration for #{sound_name}\")\n        {:error, \"Invalid sound configuration\"}\n    end\n  end\n\n  defp resolve_upload_path(filename) do\n    Soundboard.UploadsPath.file_path(filename)\n  end\n\n  defp cache_sound(sound_name, meta) do\n    :ets.insert(:sound_meta_cache, {sound_name, meta})\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player/voice_session.ex",
    "content": "defmodule Soundboard.AudioPlayer.VoiceSession do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer.State\n  alias Soundboard.Discord.Voice\n\n  @spec normalize_channel(term(), term()) :: {String.t(), String.t()} | nil\n  def normalize_channel(guild_id, channel_id) do\n    if is_nil(guild_id) or is_nil(channel_id) do\n      nil\n    else\n      {guild_id, channel_id}\n    end\n  end\n\n  @spec maintain_connection(State.t()) :: State.t()\n  def maintain_connection(%State{voice_channel: {guild_id, channel_id}} = state)\n      when not is_nil(guild_id) and not is_nil(channel_id) do\n    guild_id\n    |> maintenance_status(channel_id)\n    |> perform_maintenance(state)\n  end\n\n  def maintain_connection(%State{} = state), do: state\n\n  defp maintenance_status(guild_id, channel_id) do\n    %{\n      guild_id: guild_id,\n      channel_id: channel_id,\n      joined?: Voice.channel_id(guild_id) == to_string(channel_id),\n      ready?: voice_ready(guild_id),\n      playing?: voice_playing(guild_id)\n    }\n  end\n\n  defp voice_ready(guild_id) do\n    case safe_voice_ready(guild_id) do\n      {:ok, value} ->\n        value\n\n      {:error, reason} ->\n        Logger.warning(\"Voice readiness unavailable for guild #{guild_id}: #{inspect(reason)}\")\n        false\n    end\n  end\n\n  defp voice_playing(guild_id) do\n    case safe_voice_playing(guild_id) do\n      {:ok, value} ->\n        value\n\n      {:error, reason} ->\n        Logger.warning(\n          \"Voice playback status unavailable for guild #{guild_id}: #{inspect(reason)}; continuing maintenance\"\n        )\n\n        false\n    end\n  end\n\n  defp perform_maintenance(%{playing?: true}, state), do: state\n  defp perform_maintenance(%{joined?: true, ready?: true}, state), do: state\n\n  defp perform_maintenance(%{joined?: true} = status, state) do\n    Logger.warning(\n      \"Voice session unready for guild #{status.guild_id} in channel #{status.channel_id}, forcing leave→rejoin\"\n    )\n\n    try do\n      Voice.leave_channel(status.guild_id)\n    rescue\n      error -> Logger.warning(\"Voice leave failed during reset: #{inspect(error)}\")\n    end\n\n    Process.sleep(1_000)\n    attempt_voice_join(state, status.guild_id, status.channel_id, \"rejoin after stale session\")\n  end\n\n  defp perform_maintenance(status, state) do\n    Logger.warning(\n      \"Voice channel mismatch for guild #{status.guild_id}, attempting to rejoin #{status.channel_id}\"\n    )\n\n    attempt_voice_join(state, status.guild_id, status.channel_id, \"rejoin\")\n  end\n\n  defp attempt_voice_join(state, guild_id, channel_id, action) do\n    case safe_join_voice_channel(guild_id, channel_id) do\n      :ok ->\n        state\n\n      {:error, reason} ->\n        Logger.error(\"Failed to #{action} voice channel: #{inspect(reason)}\")\n        %{state | voice_channel: nil}\n    end\n  end\n\n  defp safe_voice_ready(guild_id) do\n    {:ok, Voice.ready?(guild_id)}\n  rescue\n    error -> {:error, {:voice_not_ready, Exception.message(error)}}\n  end\n\n  defp safe_voice_playing(guild_id) do\n    {:ok, Voice.playing?(guild_id)}\n  rescue\n    error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}\n  end\n\n  defp safe_join_voice_channel(guild_id, channel_id) do\n    Voice.join_channel(guild_id, channel_id)\n    :ok\n  rescue\n    error -> {:error, {:voice_join_failed, Exception.message(error)}}\n  catch\n    :exit, reason -> {:error, {:voice_join_failed, reason}}\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/audio_player.ex",
    "content": "defmodule Soundboard.AudioPlayer do\n  @moduledoc \"\"\"\n  Handles audio playback coordination.\n  \"\"\"\n\n  use GenServer\n\n  require Logger\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.AudioPlayer.{Notifier, PlaybackQueue, SoundLibrary, VoiceSession}\n  alias Soundboard.Discord.Handler.{AutoJoinPolicy, IdleTimeoutPolicy, VoicePresence}\n  alias Soundboard.Discord.Voice\n\n  @interrupt_watchdog_ms 35\n  @interrupt_watchdog_max_attempts 20\n\n  defmodule State do\n    @moduledoc \"\"\"\n    The state of the audio player.\n    \"\"\"\n\n    defstruct [\n      :voice_channel,\n      :current_playback,\n      :pending_request,\n      :interrupting,\n      :interrupt_watchdog_ref,\n      :interrupt_watchdog_attempt,\n      :idle_timeout_ref\n    ]\n\n    @type t :: %__MODULE__{\n            voice_channel: {String.t(), String.t()} | nil,\n            current_playback: map() | nil,\n            pending_request: map() | nil,\n            interrupting: boolean() | nil,\n            interrupt_watchdog_ref: reference() | nil,\n            interrupt_watchdog_attempt: non_neg_integer() | nil,\n            idle_timeout_ref: {reference(), reference()} | nil\n          }\n  end\n\n  def start_link(_opts) do\n    GenServer.start_link(__MODULE__, %State{}, name: __MODULE__)\n  end\n\n  def play_sound(sound_name, actor) do\n    GenServer.cast(__MODULE__, {:play_sound, sound_name, actor})\n  end\n\n  def stop_sound do\n    GenServer.cast(__MODULE__, :stop_sound)\n  end\n\n  def set_voice_channel(guild_id, channel_id) do\n    GenServer.cast(__MODULE__, {:set_voice_channel, guild_id, channel_id})\n  end\n\n  def last_user_left(guild_id) do\n    GenServer.cast(__MODULE__, {:last_user_left, guild_id})\n  end\n\n  def user_joined_channel(guild_id) do\n    GenServer.cast(__MODULE__, {:user_joined_channel, guild_id})\n  end\n\n  def playback_finished(guild_id) do\n    GenServer.cast(__MODULE__, {:playback_finished, guild_id})\n  end\n\n  def current_voice_channel do\n    {:ok, GenServer.call(__MODULE__, :get_voice_channel)}\n  rescue\n    error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}\n  catch\n    :exit, reason -> {:error, {:voice_channel_unavailable, reason}}\n  end\n\n  @doc \"\"\"\n  Removes any cached metadata for the given `sound_name` so future plays use fresh data.\n  \"\"\"\n  def invalidate_cache(sound_name), do: SoundLibrary.invalidate_cache(sound_name)\n\n  @impl true\n  def init(state) do\n    SoundLibrary.ensure_cache()\n    schedule_voice_check()\n\n    {:ok,\n     %{\n       state\n       | current_playback: nil,\n         pending_request: nil,\n         interrupting: false,\n         interrupt_watchdog_ref: nil,\n         interrupt_watchdog_attempt: 0,\n         idle_timeout_ref: nil\n     }}\n  end\n\n  @impl true\n  def handle_cast({:set_voice_channel, guild_id, channel_id}, state) do\n    next_state =\n      case VoiceSession.normalize_channel(guild_id, channel_id) do\n        nil ->\n          state\n          |> PlaybackQueue.clear_all()\n          |> cancel_idle_timeout()\n          |> Map.put(:voice_channel, nil)\n\n        voice_channel ->\n          new_state =\n            state\n            |> cancel_idle_timeout()\n            |> Map.put(:voice_channel, voice_channel)\n\n          if AutoJoinPolicy.mode() == :play, do: schedule_idle_timeout(new_state), else: new_state\n      end\n\n    {:noreply, next_state}\n  end\n\n  def handle_cast(:stop_sound, %{voice_channel: {guild_id, _channel_id}} = state) do\n    Voice.stop(guild_id)\n    Notifier.sound_played(\"All sounds stopped\", \"System\")\n\n    {:noreply, PlaybackQueue.clear_all(state)}\n  end\n\n  def handle_cast(:stop_sound, state) do\n    Notifier.error(\"Bot is not connected to a voice channel\")\n    {:noreply, state}\n  end\n\n  def handle_cast({:playback_finished, guild_id}, state) do\n    {:noreply, PlaybackQueue.handle_playback_finished(state, guild_id)}\n  end\n\n  def handle_cast({:play_sound, sound_name, actor}, %{voice_channel: nil} = state) do\n    if AutoJoinPolicy.mode() == :play do\n      case try_auto_join(actor) do\n        {:ok, {guild_id, channel_id}} ->\n          new_state =\n            state\n            |> Map.put(:voice_channel, {guild_id, channel_id})\n            |> schedule_idle_timeout()\n\n          do_play_sound(sound_name, actor, new_state)\n\n        :not_found ->\n          Notifier.error(\"Bot is not connected to a voice channel. Use !join in Discord first.\")\n          {:noreply, state}\n      end\n    else\n      Notifier.error(\"Bot is not connected to a voice channel. Use !join in Discord first.\")\n      {:noreply, state}\n    end\n  end\n\n  def handle_cast({:play_sound, sound_name, actor}, state) do\n    do_play_sound(sound_name, actor, state)\n  end\n\n  def handle_cast({:last_user_left, guild_id}, %{voice_channel: {guild_id, _}} = state) do\n    case AutoJoinPolicy.mode() do\n      mode when mode in [:presence, :play] ->\n        Logger.info(\"Last user left (#{mode} mode); leaving guild #{guild_id}\")\n        safely_leave(guild_id)\n\n        new_state =\n          state\n          |> cancel_idle_timeout()\n          |> PlaybackQueue.clear_all()\n          |> Map.put(:voice_channel, nil)\n\n        {:noreply, new_state}\n\n      false ->\n        Logger.info(\"Last user left (false mode); starting idle timer\")\n        {:noreply, reset_idle_timeout(state)}\n    end\n  end\n\n  def handle_cast({:last_user_left, _guild_id}, state), do: {:noreply, state}\n\n  def handle_cast({:user_joined_channel, _guild_id}, state) do\n    {:noreply, cancel_idle_timeout(state)}\n  end\n\n  @impl true\n  def handle_call(:get_voice_channel, _from, state) do\n    {:reply, state.voice_channel, state}\n  end\n\n  @impl true\n  def handle_info(\n        {:idle_timeout, token},\n        %{idle_timeout_ref: {_ref, token}, voice_channel: {guild_id, _}} = state\n      ) do\n    Logger.info(\"Voice idle timeout in guild #{guild_id}; leaving channel\")\n    safely_leave(guild_id)\n\n    new_state =\n      %{state | idle_timeout_ref: nil}\n      |> PlaybackQueue.clear_all()\n      |> Map.put(:voice_channel, nil)\n\n    {:noreply, new_state}\n  end\n\n  def handle_info({:idle_timeout, _stale_token}, state), do: {:noreply, state}\n\n  @impl true\n  def handle_info(:check_voice_connection, state) do\n    schedule_voice_check()\n    {:noreply, VoiceSession.maintain_connection(state)}\n  end\n\n  @impl true\n  def handle_info({ref, result}, %{current_playback: %{task_ref: ref}} = state) do\n    Process.demonitor(ref, [:flush])\n    {:noreply, PlaybackQueue.handle_task_result(state, result)}\n  end\n\n  @impl true\n  def handle_info(\n        {:DOWN, ref, :process, _pid, reason},\n        %{current_playback: %{task_ref: ref}} = state\n      ) do\n    {:noreply, PlaybackQueue.handle_task_down(state, reason)}\n  end\n\n  @impl true\n  def handle_info({:interrupt_watchdog, guild_id, attempt}, state) do\n    {:noreply,\n     PlaybackQueue.handle_interrupt_watchdog(\n       state,\n       guild_id,\n       attempt,\n       @interrupt_watchdog_max_attempts,\n       @interrupt_watchdog_ms\n     )}\n  end\n\n  @impl true\n  def handle_info(_, state), do: {:noreply, state}\n\n  defp do_play_sound(sound_name, actor, %{voice_channel: voice_channel} = state) do\n    case PlaybackQueue.build_request(voice_channel, sound_name, actor) do\n      {:ok, request} ->\n        new_state =\n          if AutoJoinPolicy.mode() == :play, do: reset_idle_timeout(state), else: state\n\n        {:noreply, PlaybackQueue.enqueue(new_state, request, @interrupt_watchdog_ms)}\n\n      {:error, reason} ->\n        Notifier.error(reason)\n        {:noreply, state}\n    end\n  end\n\n  defp try_auto_join(actor) do\n    case actor_discord_id(actor) do\n      nil -> :not_found\n      discord_id -> find_and_join_voice(discord_id)\n    end\n  end\n\n  defp find_and_join_voice(discord_id) do\n    case VoicePresence.find_user_voice_channel(discord_id) do\n      {:ok, {guild_id, channel_id}} ->\n        Logger.info(\n          \"Auto-joining channel #{channel_id} in guild #{guild_id} for user #{discord_id}\"\n        )\n\n        Voice.join_channel(guild_id, channel_id)\n        {:ok, {guild_id, channel_id}}\n\n      :not_found ->\n        Logger.info(\"User #{discord_id} not in a voice channel; skipping auto-join\")\n        :not_found\n    end\n  rescue\n    error ->\n      Logger.warning(\"Auto-join failed: #{inspect(error)}\")\n      :not_found\n  end\n\n  defp safely_leave(guild_id) do\n    Voice.leave_channel(guild_id)\n  rescue\n    error -> Logger.warning(\"Voice leave failed: #{inspect(error)}\")\n  end\n\n  defp actor_discord_id(%User{discord_id: id}) when is_binary(id) and id != \"\", do: id\n  defp actor_discord_id(%{discord_id: id}) when is_binary(id) and id != \"\", do: id\n  defp actor_discord_id(_), do: nil\n\n  defp schedule_idle_timeout(state) do\n    case IdleTimeoutPolicy.timeout_ms() do\n      nil ->\n        state\n\n      ms ->\n        token = make_ref()\n        ref = Process.send_after(self(), {:idle_timeout, token}, ms)\n        %{state | idle_timeout_ref: {ref, token}}\n    end\n  end\n\n  defp cancel_idle_timeout(%{idle_timeout_ref: nil} = state), do: state\n\n  defp cancel_idle_timeout(%{idle_timeout_ref: {ref, _token}} = state) do\n    Process.cancel_timer(ref)\n    %{state | idle_timeout_ref: nil}\n  end\n\n  defp reset_idle_timeout(state) do\n    state |> cancel_idle_timeout() |> schedule_idle_timeout()\n  end\n\n  defp schedule_voice_check do\n    if Application.get_env(:soundboard, __MODULE__, [])[:voice_maintenance_enabled] != false do\n      Process.send_after(self(), :check_voice_connection, 30_000)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/bot_identity.ex",
    "content": "defmodule Soundboard.Discord.BotIdentity do\n  @moduledoc false\n\n  alias EDA.API.User\n  alias EDA.Cache\n\n  def fetch do\n    case Cache.me() do\n      nil -> fetch_from_api()\n      user -> {:ok, normalize_user(user)}\n    end\n  end\n\n  defp fetch_from_api do\n    case User.me() do\n      {:ok, user} ->\n        Cache.put_me(user)\n        {:ok, normalize_user(user)}\n\n      other ->\n        other\n    end\n  end\n\n  defp normalize_user(%{id: id}), do: %{id: id}\n  defp normalize_user(%{\"id\" => id}), do: %{id: id}\n  defp normalize_user(_), do: %{}\nend\n"
  },
  {
    "path": "lib/soundboard/discord/consumer.ex",
    "content": "defmodule Soundboard.Discord.Consumer do\n  @moduledoc false\n  @behaviour EDA.Consumer\n\n  alias Soundboard.Discord.Handler\n\n  @impl true\n  def handle_event({event_name, payload}) do\n    event = {event_name, payload, nil}\n    Handler.dispatch_event(event)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/guild_cache.ex",
    "content": "defmodule Soundboard.Discord.GuildCache do\n  @moduledoc false\n\n  alias EDA.Cache\n\n  def all do\n    Cache.guilds()\n    |> Enum.map(&normalize_guild/1)\n  end\n\n  def get(guild_id) do\n    case Cache.get_guild(to_id(guild_id)) do\n      nil -> :error\n      guild -> {:ok, normalize_guild(guild)}\n    end\n  end\n\n  def get!(guild_id) do\n    case get(guild_id) do\n      {:ok, guild} -> guild\n      _ -> raise \"guild #{guild_id} not found in cache\"\n    end\n  end\n\n  defp normalize_guild(guild) do\n    guild_id = map_get(guild, \"id\")\n    channels = Cache.channels_for_guild(guild_id)\n    voice_states = Cache.voice_states(guild_id)\n\n    %{\n      id: guild_id,\n      name: map_get(guild, \"name\"),\n      channels: normalize_channels(channels, guild_id),\n      voice_states: Enum.map(voice_states, &normalize_voice_state(&1, guild_id))\n    }\n  end\n\n  defp normalize_channels(channels, guild_id) do\n    Enum.reduce(channels, %{}, fn channel, acc ->\n      channel_id = map_get(channel, \"id\")\n\n      Map.put(acc, channel_id, %{\n        id: channel_id,\n        guild_id: guild_id,\n        name: map_get(channel, \"name\")\n      })\n    end)\n  end\n\n  defp normalize_voice_state(voice_state, guild_id) do\n    %{\n      guild_id: guild_id,\n      channel_id: map_get(voice_state, \"channel_id\"),\n      user_id: map_get(voice_state, \"user_id\"),\n      session_id: map_get(voice_state, \"session_id\")\n    }\n  end\n\n  defp map_get(map, key) when is_map(map) do\n    case map do\n      %{^key => value} ->\n        value\n\n      _ ->\n        atom_key = String.to_atom(key)\n        Map.get(map, atom_key)\n    end\n  end\n\n  defp to_id(value) when is_integer(value), do: Integer.to_string(value)\n  defp to_id(value), do: to_string(value)\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/auto_join_policy.ex",
    "content": "defmodule Soundboard.Discord.Handler.AutoJoinPolicy do\n  @moduledoc false\n\n  @type mode :: :presence | :play | false\n\n  @spec mode() :: mode()\n  def mode do\n    case Application.get_env(:soundboard, :env) do\n      :test -> :play\n      _ -> parse_mode(System.get_env(\"AUTO_JOIN\"))\n    end\n  end\n\n  defp parse_mode(nil), do: :play\n\n  defp parse_mode(value) do\n    case value |> String.trim() |> String.downcase() do\n      v when v in [\"presence\", \"true\", \"1\", \"yes\"] -> :presence\n      \"play\" -> :play\n      _ -> false\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/command_handler.ex",
    "content": "defmodule Soundboard.Discord.Handler.CommandHandler do\n  @moduledoc false\n\n  alias Soundboard.Discord.Handler.VoiceRuntime\n  alias Soundboard.Discord.Message\n  alias Soundboard.PublicURL\n\n  def handle_message(%{content: \"!join\"} = msg) do\n    case VoiceRuntime.user_voice_channel(msg.guild_id, msg.author.id) do\n      nil ->\n        Message.create(msg.channel_id, \"You need to be in a voice channel!\")\n\n      channel_id ->\n        VoiceRuntime.join_voice_channel(msg.guild_id, channel_id)\n        Message.create(msg.channel_id, joined_message())\n    end\n  end\n\n  def handle_message(%{content: \"!leave\", guild_id: guild_id, channel_id: channel_id})\n      when not is_nil(guild_id) do\n    VoiceRuntime.leave_voice_channel(guild_id)\n    Message.create(channel_id, \"Left the voice channel!\")\n  end\n\n  def handle_message(_msg), do: :ignore\n\n  defp joined_message do\n    url = PublicURL.current()\n\n    \"\"\"\n    Joined your voice channel!\n    Access the soundboard here: #{url}\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/idle_timeout_policy.ex",
    "content": "defmodule Soundboard.Discord.Handler.IdleTimeoutPolicy do\n  @moduledoc false\n\n  require Logger\n\n  @default_seconds 600\n\n  @spec timeout_ms() :: pos_integer() | nil\n  def timeout_ms do\n    case raw_seconds() do\n      n when n <= 0 -> nil\n      n -> n * 1_000\n    end\n  end\n\n  defp raw_seconds do\n    case System.get_env(\"VOICE_IDLE_TIMEOUT_SECONDS\") do\n      nil ->\n        @default_seconds\n\n      raw ->\n        case raw |> String.trim() |> Integer.parse() do\n          {n, \"\"} ->\n            n\n\n          _ ->\n            Logger.warning(\"Invalid VOICE_IDLE_TIMEOUT_SECONDS=#{inspect(raw)}; using default\")\n            @default_seconds\n        end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/sound_effects.ex",
    "content": "defmodule Soundboard.Discord.Handler.SoundEffects do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.{AudioPlayer, Sounds}\n  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceRuntime}\n\n  def handle_join(user_id, previous_state, guild_id, channel_id) do\n    is_join_event =\n      case previous_state do\n        nil -> true\n        {nil, _} -> true\n        {prev_channel, _} -> prev_channel != channel_id\n      end\n\n    Logger.info(\n      \"Join sound check - User: #{user_id}, Previous: #{inspect(previous_state)}, New channel: #{channel_id}, Is join: #{is_join_event}\"\n    )\n\n    if is_join_event do\n      play_join_sound(user_id, guild_id, channel_id)\n    else\n      :noop\n    end\n  end\n\n  def handle_leave(user_id) do\n    case Sounds.get_user_leave_sound_by_discord_id(user_id) do\n      leave_sound when is_binary(leave_sound) ->\n        Logger.info(\"Playing leave sound: #{leave_sound}\")\n        AudioPlayer.play_sound(leave_sound, \"System\")\n\n      _ ->\n        :noop\n    end\n  end\n\n  defp play_join_sound(user_id, guild_id, channel_id) do\n    join_sound = Sounds.get_user_join_sound_by_discord_id(user_id)\n\n    Logger.info(\"Join sound query result for user #{user_id}: #{inspect(join_sound)}\")\n\n    case join_sound do\n      join_sound when is_binary(join_sound) ->\n        Logger.info(\"Playing join sound immediately: #{join_sound}\")\n        maybe_join_for_sound(guild_id, channel_id)\n        AudioPlayer.play_sound(join_sound, \"System\")\n\n      _ ->\n        Logger.info(\"No join sound found for user #{user_id}\")\n        :noop\n    end\n  end\n\n  defp maybe_join_for_sound(guild_id, channel_id) do\n    if AutoJoinPolicy.mode() == :play && VoiceRuntime.get_current_voice_channel() == nil do\n      Logger.info(\"Auto-joining #{guild_id}/#{channel_id} to play join sound\")\n      VoiceRuntime.join_voice_channel(guild_id, channel_id)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_commands.ex",
    "content": "defmodule Soundboard.Discord.Handler.VoiceCommands do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer\n  alias Soundboard.Discord.{BotIdentity, Voice}\n\n  def join_voice_channel(guild_id, channel_id) do\n    execute(\n      connected_to_discord?(),\n      \"Skipping join_voice_channel - not connected to Discord\",\n      fn ->\n        Logger.info(\"Bot joining voice channel #{channel_id} in guild #{guild_id}\")\n        run(\"join voice channel\", fn -> Voice.join_channel(guild_id, channel_id) end)\n      end,\n      fn -> AudioPlayer.set_voice_channel(guild_id, channel_id) end,\n      fn error_msg -> Logger.error(\"Error joining voice channel: #{error_msg}\") end\n    )\n  end\n\n  def leave_voice_channel(guild_id) do\n    execute(\n      connected_to_discord?(),\n      \"Skipping leave_voice_channel - not connected to Discord\",\n      fn ->\n        Logger.info(\"Bot leaving voice channel in guild #{guild_id}\")\n        run(\"leave voice channel\", fn -> Voice.leave_channel(guild_id) end)\n      end,\n      fn -> AudioPlayer.set_voice_channel(nil, nil) end,\n      fn error_msg -> Logger.error(\"Error leaving voice channel: #{error_msg}\") end\n    )\n  end\n\n  def connected_to_discord? do\n    ready = :persistent_term.get(:soundboard_bot_ready, false)\n\n    if ready do\n      try do\n        case BotIdentity.fetch() do\n          {:ok, _} ->\n            Logger.debug(\"Discord connection check: Connected and ready\")\n            true\n\n          error ->\n            Logger.debug(\"Discord connection check failed: #{inspect(error)}\")\n            false\n        end\n      rescue\n        error ->\n          Logger.debug(\"Discord connection check error: #{inspect(error)}\")\n          false\n      end\n    else\n      Logger.debug(\"Discord connection check: Bot not ready (READY event not received)\")\n      false\n    end\n  end\n\n  defp execute(true, _skip_message, command_fun, success_fun, error_fun) do\n    case command_fun.() do\n      :ok -> success_fun.()\n      {:error, error_msg} -> error_fun.(error_msg)\n    end\n  end\n\n  defp execute(false, skip_message, _command_fun, _success_fun, _error_fun) do\n    Logger.warning(skip_message)\n  end\n\n  defp run(action, command) do\n    case safely_run(command) do\n      :ok ->\n        :ok\n\n      {:error, error_msg} ->\n        if rate_limited?(error_msg) do\n          Logger.warning(\"Rate limited while trying to #{action}, retrying in 5 seconds...\")\n          Process.sleep(5000)\n          safely_run(command)\n        else\n          {:error, error_msg}\n        end\n    end\n  end\n\n  defp safely_run(command) do\n    case command.() do\n      :ok -> :ok\n      other -> {:error, inspect(other)}\n    end\n  rescue\n    error -> {:error, Exception.message(error)}\n  end\n\n  defp rate_limited?(error_msg) do\n    is_binary(error_msg) and String.contains?(String.downcase(error_msg), \"rate limit\")\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_presence.ex",
    "content": "defmodule Soundboard.Discord.Handler.VoicePresence do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer\n  alias Soundboard.Discord.{BotIdentity, GuildCache}\n\n  def current_voice_channel do\n    with {:ok, bot_id} <- bot_id() do\n      case find_bot_voice_channel(bot_id) do\n        nil -> :not_found\n        channel -> {:ok, channel}\n      end\n    end\n  end\n\n  def user_voice_channel(guild_id, user_id) do\n    case GuildCache.get(guild_id) do\n      {:ok, guild} -> find_user_voice_channel(guild, user_id)\n      :error -> {:error, {:guild_unavailable, guild_id}}\n    end\n  end\n\n  def bot_user?(user_id) do\n    case bot_id() do\n      {:ok, bot_id} -> to_string(bot_id) == to_string(user_id)\n      _ -> false\n    end\n  end\n\n  def bot_id do\n    case BotIdentity.fetch() do\n      {:ok, %{id: id}} when not is_nil(id) -> {:ok, id}\n      {:ok, _} -> {:error, :bot_identity_missing}\n      {:error, reason} -> {:error, {:bot_identity_unavailable, reason}}\n      other -> {:error, {:bot_identity_unavailable, other}}\n    end\n  end\n\n  def cached_guilds do\n    {:ok, GuildCache.all() |> Enum.to_list()}\n  rescue\n    error -> {:error, {:guild_cache_unavailable, Exception.message(error)}}\n  end\n\n  def find_user_voice_channel(discord_id) do\n    case cached_guilds() do\n      {:ok, guilds} ->\n        Enum.find_value(guilds, :not_found, &find_in_guild(&1, discord_id))\n\n      {:error, reason} ->\n        Logger.debug(\"Guild cache unavailable for user voice channel lookup: #{inspect(reason)}\")\n        :not_found\n    end\n  end\n\n  defp find_in_guild(guild, discord_id) do\n    target = to_string(discord_id)\n\n    case Enum.find(guild.voice_states, fn vs -> to_string(vs.user_id) == target end) do\n      %{channel_id: channel_id} when not is_nil(channel_id) -> {:ok, {guild.id, channel_id}}\n      _ -> nil\n    end\n  end\n\n  def users_in_channel(guild_id, channel_id) do\n    cond do\n      not valid_discord_id?(guild_id) ->\n        {:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}\n\n      is_nil(channel_id) ->\n        {:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}\n\n      true ->\n        count_users_in_channel(guild_id, channel_id)\n    end\n  end\n\n  defp count_users_in_channel(guild_id, channel_id) do\n    case GuildCache.get(guild_id) do\n      {:ok, guild} ->\n        bot_id = bot_id_value()\n        voice_states = List.wrap(guild.voice_states)\n\n        users_in_channel =\n          voice_states\n          |> Enum.count(fn vs -> vs.channel_id == channel_id && vs.user_id != bot_id end)\n\n        log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states)\n        {:ok, users_in_channel}\n\n      :error ->\n        {:error, {:guild_unavailable, guild_id}}\n    end\n  end\n\n  defp bot_id_value do\n    case bot_id() do\n      {:ok, id} -> id\n      _ -> nil\n    end\n  end\n\n  defp log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states) do\n    Logger.info(\"\"\"\n    Voice state check:\n    Channel ID: #{channel_id}\n    Users in channel: #{users_in_channel} (excluding bot)\n    Bot ID: #{bot_id}\n    Voice states: #{inspect(voice_states)}\n    \"\"\")\n  end\n\n  defp find_bot_voice_channel(bot_id) do\n    case cached_guilds() do\n      {:ok, []} ->\n        fallback_voice_channel()\n\n      {:ok, guilds} ->\n        find_voice_channel_in_guilds(guilds, bot_id) || fallback_voice_channel()\n\n      {:error, reason} ->\n        Logger.debug(\"Guild cache unavailable for bot voice channel lookup: #{inspect(reason)}\")\n        fallback_voice_channel()\n    end\n  end\n\n  defp find_user_voice_channel(guild, user_id) do\n    case Enum.find(guild.voice_states, fn vs -> vs.user_id == user_id end) do\n      nil -> :not_found\n      voice_state -> {:ok, voice_state.channel_id}\n    end\n  end\n\n  defp find_voice_channel_in_guilds(guilds, bot_id) do\n    Enum.find_value(guilds, &voice_channel_for_guild(&1, bot_id))\n  end\n\n  defp voice_channel_for_guild(guild, bot_id) do\n    guild.voice_states\n    |> List.wrap()\n    |> Enum.find_value(fn\n      %{user_id: ^bot_id, channel_id: channel_id} when not is_nil(channel_id) ->\n        {guild.id, channel_id}\n\n      _ ->\n        nil\n    end)\n  end\n\n  defp fallback_voice_channel do\n    case AudioPlayer.current_voice_channel() do\n      {:ok, {gid, cid}} when not is_nil(gid) and not is_nil(cid) ->\n        {gid, cid}\n\n      {:ok, _} ->\n        nil\n\n      {:error, reason} ->\n        Logger.debug(\"Audio player voice channel unavailable: #{inspect(reason)}\")\n        nil\n    end\n  end\n\n  defp valid_discord_id?(value), do: is_integer(value) or (is_binary(value) and value != \"\")\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler/voice_runtime.ex",
    "content": "defmodule Soundboard.Discord.Handler.VoiceRuntime do\n  @moduledoc false\n\n  require Logger\n\n  alias Soundboard.AudioPlayer\n  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceCommands, VoicePresence}\n  alias Soundboard.Discord.Voice\n\n  @type runtime_action :: {:schedule_recheck_alone, String.t(), String.t(), non_neg_integer()}\n\n  def bootstrap do\n    Logger.info(\"Starting DiscordHandler...\")\n    if AutoJoinPolicy.mode() == :presence, do: start_guild_check_task()\n    :ok\n  end\n\n  def join_voice_channel(guild_id, channel_id),\n    do: VoiceCommands.join_voice_channel(guild_id, channel_id)\n\n  def leave_voice_channel(guild_id), do: VoiceCommands.leave_voice_channel(guild_id)\n\n  @spec handle_connect(map()) :: [runtime_action()]\n  def handle_connect(payload) do\n    case AutoJoinPolicy.mode() do\n      :presence -> handle_auto_join_leave(payload)\n      false -> handle_user_rejoin_cancel(payload)\n      :play -> []\n    end\n  end\n\n  @spec handle_disconnect(map()) :: [runtime_action()]\n  def handle_disconnect(payload) do\n    if bot_user?(payload.user_id) do\n      []\n    else\n      handle_bot_alone_check(payload.guild_id)\n    end\n  end\n\n  @spec recheck_alone(String.t(), String.t()) :: [runtime_action()]\n  def recheck_alone(guild_id, channel_id) do\n    case current_voice_channel_status() do\n      {:ok, {^guild_id, ^channel_id}} -> handle_recheck_alone(guild_id, channel_id)\n      _ -> Logger.debug(\"Recheck skipped; voice target changed\")\n    end\n\n    []\n  end\n\n  def get_current_voice_channel do\n    case current_voice_channel_status() do\n      {:ok, channel} -> channel\n      _ -> nil\n    end\n  end\n\n  def user_voice_channel(guild_id, user_id) do\n    case VoicePresence.user_voice_channel(guild_id, user_id) do\n      {:ok, channel_id} -> channel_id\n      _ -> nil\n    end\n  end\n\n  def bot_user?(user_id), do: VoicePresence.bot_user?(user_id)\n\n  defp start_guild_check_task do\n    Task.start(fn ->\n      Logger.info(\"Starting voice channel check task...\")\n      Process.sleep(5000)\n      check_guilds()\n    end)\n  end\n\n  defp check_guilds do\n    case VoicePresence.cached_guilds() do\n      {:ok, []} ->\n        Logger.warning(\"No guilds found in cache. Discord may not be ready.\")\n\n      {:ok, guilds} ->\n        process_guilds(guilds)\n\n      {:error, reason} ->\n        Logger.warning(\"Guild cache unavailable during bootstrap: #{inspect(reason)}\")\n    end\n  end\n\n  defp process_guilds(guilds) do\n    Logger.info(\"Checking #{length(guilds)} guilds for active voice channels\")\n    Enum.each(guilds, &check_and_join_voice/1)\n  end\n\n  defp check_and_join_voice(guild) do\n    voice_states = guild.voice_states\n    bot_id = current_bot_id()\n\n    case Enum.find(voice_states, fn vs -> vs.user_id != bot_id && vs.channel_id != nil end) do\n      %{channel_id: channel_id} ->\n        Logger.info(\"Auto-joining guild #{guild.id} channel #{channel_id} during bootstrap\")\n        Voice.join_channel(guild.id, channel_id)\n        AudioPlayer.set_voice_channel(guild.id, channel_id)\n\n      _ ->\n        :ok\n    end\n  end\n\n  defp handle_recheck_alone(guild_id, channel_id) do\n    case VoicePresence.users_in_channel(guild_id, channel_id) do\n      {:ok, users} ->\n        Logger.info(\"Recheck alone: channel #{channel_id} now has #{users} non-bot users\")\n        maybe_act_if_bot_alone(guild_id, channel_id, users)\n\n      {:error, reason} ->\n        Logger.warning(\"Recheck skipped because voice state was unavailable: #{inspect(reason)}\")\n    end\n  end\n\n  defp maybe_act_if_bot_alone(guild_id, _channel_id, 0) do\n    Logger.info(\"Recheck confirms bot is alone; leaving channel\")\n    bot_alone_action(guild_id)\n  end\n\n  defp maybe_act_if_bot_alone(_guild_id, _channel_id, _users), do: :ok\n\n  defp handle_bot_alone_check(_guild_id) do\n    case current_voice_channel_status() do\n      {:ok, {guild_id, channel_id}} -> check_and_maybe_act(guild_id, channel_id)\n      _ -> []\n    end\n  end\n\n  defp check_and_maybe_act(guild_id, channel_id) do\n    case VoicePresence.users_in_channel(guild_id, channel_id) do\n      {:ok, 0} ->\n        Logger.info(\"No non-bot users remaining in channel, acting on bot alone\")\n        bot_alone_action(guild_id)\n        []\n\n      {:ok, users} ->\n        Logger.info(\"Non-bot users detected (#{users}); scheduling recheck in 1.5s\")\n        [schedule_recheck(guild_id, channel_id)]\n\n      {:error, reason} ->\n        Logger.warning(\n          \"Skipping leave check because voice state was unavailable: #{inspect(reason)}\"\n        )\n\n        []\n    end\n  end\n\n  defp bot_alone_action(guild_id) do\n    case AutoJoinPolicy.mode() do\n      :presence -> leave_voice_channel(guild_id)\n      _ -> AudioPlayer.last_user_left(guild_id)\n    end\n  end\n\n  defp handle_auto_join_leave(payload) do\n    if bot_user?(payload.user_id) do\n      Logger.debug(\"Ignoring bot's own voice state update in auto-join logic\")\n      []\n    else\n      process_user_voice_update(payload)\n    end\n  end\n\n  defp handle_user_rejoin_cancel(payload) do\n    if bot_user?(payload.user_id) do\n      []\n    else\n      case current_voice_channel_status() do\n        {:ok, {guild_id, channel_id}}\n        when guild_id == payload.guild_id and channel_id == payload.channel_id ->\n          Logger.debug(\"User rejoined bot's channel (false mode); cancelling idle timer\")\n          AudioPlayer.user_joined_channel(guild_id)\n          []\n\n        _ ->\n          []\n      end\n    end\n  end\n\n  defp process_user_voice_update(payload) do\n    case current_voice_channel_status() do\n      :not_found when payload.channel_id != nil ->\n        handle_bot_not_in_voice(payload)\n\n      {:ok, {guild_id, current_channel_id}} when current_channel_id != payload.channel_id ->\n        handle_bot_in_different_channel(guild_id, current_channel_id)\n\n      _ ->\n        Logger.debug(\"No action needed for voice state update\")\n        []\n    end\n  end\n\n  defp handle_bot_not_in_voice(payload) do\n    case VoicePresence.users_in_channel(payload.guild_id, payload.channel_id) do\n      {:ok, users_in_channel} ->\n        Logger.info(\"Found #{users_in_channel} users in channel #{payload.channel_id}\")\n        maybe_join_channel_for_payload(payload, users_in_channel)\n        []\n\n      {:error, reason} ->\n        Logger.warning(\n          \"Skipping auto-join because voice state was unavailable: #{inspect(reason)}\"\n        )\n\n        []\n    end\n  end\n\n  defp handle_bot_in_different_channel(guild_id, current_channel_id) do\n    case VoicePresence.users_in_channel(guild_id, current_channel_id) do\n      {:ok, users} ->\n        Logger.info(\"Current channel #{current_channel_id} has #{users} users\")\n        handle_current_channel_users(guild_id, current_channel_id, users)\n\n      {:error, reason} ->\n        Logger.warning(\n          \"Skipping channel switch handling because voice state was unavailable: #{inspect(reason)}\"\n        )\n\n        []\n    end\n  end\n\n  defp maybe_join_channel_for_payload(_payload, users_in_channel) when users_in_channel <= 0,\n    do: :ok\n\n  defp maybe_join_channel_for_payload(payload, users_in_channel) do\n    if Voice.ready?(payload.guild_id) do\n      Logger.debug(\"Bot already connected to voice in guild #{payload.guild_id}, skipping join\")\n    else\n      Logger.info(\"Joining channel #{payload.channel_id} with #{users_in_channel} users\")\n      join_voice_channel(payload.guild_id, payload.channel_id)\n    end\n  end\n\n  defp handle_current_channel_users(guild_id, current_channel_id, 0) do\n    Logger.info(\"Bot is alone in channel #{current_channel_id}, leaving\")\n    leave_voice_channel(guild_id)\n    []\n  end\n\n  defp handle_current_channel_users(guild_id, current_channel_id, _users) do\n    [schedule_recheck(guild_id, current_channel_id)]\n  end\n\n  defp schedule_recheck(guild_id, channel_id),\n    do: {:schedule_recheck_alone, guild_id, channel_id, 1_500}\n\n  defp current_voice_channel_status do\n    case VoicePresence.current_voice_channel() do\n      {:ok, channel} ->\n        {:ok, channel}\n\n      :not_found ->\n        :not_found\n\n      {:error, reason} ->\n        Logger.debug(\"Current voice channel unavailable: #{inspect(reason)}\")\n        :not_found\n    end\n  end\n\n  defp current_bot_id do\n    case VoicePresence.bot_id() do\n      {:ok, bot_id} -> bot_id\n      _ -> nil\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/handler.ex",
    "content": "defmodule Soundboard.Discord.Handler do\n  @moduledoc \"\"\"\n  Handles the Discord events.\n  \"\"\"\n  use GenServer\n  require Logger\n\n  alias Soundboard.Discord.Handler.{CommandHandler, SoundEffects, VoiceRuntime}\n\n  defmodule State do\n    @moduledoc \"\"\"\n    Handles the state of the Discord handler.\n    \"\"\"\n    use GenServer\n\n    def start_link(_) do\n      GenServer.start_link(__MODULE__, %{}, name: __MODULE__)\n    end\n\n    def init(_) do\n      {:ok, %{voice_states: %{}}}\n    end\n\n    def get_state(user_id) do\n      GenServer.call(__MODULE__, {:get_state, user_id})\n    catch\n      :exit, _ -> nil\n    end\n\n    def update_state(user_id, channel_id, session_id) do\n      GenServer.cast(__MODULE__, {:update_state, user_id, channel_id, session_id})\n    catch\n      :exit, _ -> :error\n    end\n\n    def handle_call({:get_state, user_id}, _from, state) do\n      {:reply, Map.get(state.voice_states, user_id), state}\n    end\n\n    def handle_cast({:update_state, user_id, channel_id, session_id}, state) do\n      {:noreply,\n       %{state | voice_states: Map.put(state.voice_states, user_id, {channel_id, session_id})}}\n    end\n  end\n\n  def init do\n    VoiceRuntime.bootstrap()\n  end\n\n  def start_link(_opts) do\n    GenServer.start_link(__MODULE__, [], name: __MODULE__)\n  end\n\n  def dispatch_event(event) do\n    case Process.whereis(__MODULE__) do\n      nil ->\n        Logger.warning(\"DiscordHandler is not running; dropping event #{inspect(elem(event, 0))}\")\n        :error\n\n      _pid ->\n        GenServer.cast(__MODULE__, {:eda_event, event})\n        :ok\n    end\n  end\n\n  @impl GenServer\n  def init([]) do\n    init()\n    {:ok, nil}\n  end\n\n  def handle_event({:VOICE_STATE_UPDATE, %{channel_id: nil} = payload, _ws_state}) do\n    Logger.info(\"User #{payload.user_id} disconnected from voice\")\n    State.update_state(payload.user_id, nil, payload.session_id)\n\n    if VoiceRuntime.bot_user?(payload.user_id) do\n      Logger.debug(\"Skipping leave sound lookup for bot user #{payload.user_id}\")\n    else\n      SoundEffects.handle_leave(payload.user_id)\n    end\n\n    VoiceRuntime.handle_disconnect(payload)\n  end\n\n  def handle_event({:VOICE_STATE_UPDATE, payload, _ws_state}) do\n    Logger.info(\"Voice state update received: #{inspect(payload)}\")\n\n    if VoiceRuntime.bot_user?(payload.user_id) do\n      Logger.info(\n        \"BOT VOICE STATE UPDATE - Bot joined channel #{payload.channel_id} in guild #{payload.guild_id}\"\n      )\n    end\n\n    previous_state = State.get_state(payload.user_id)\n    State.update_state(payload.user_id, payload.channel_id, payload.session_id)\n\n    runtime_actions = VoiceRuntime.handle_connect(payload)\n\n    if VoiceRuntime.bot_user?(payload.user_id) do\n      Logger.debug(\"Skipping join sound lookup for bot user #{payload.user_id}\")\n    else\n      SoundEffects.handle_join(\n        payload.user_id,\n        previous_state,\n        payload.guild_id,\n        payload.channel_id\n      )\n    end\n\n    runtime_actions\n  end\n\n  def handle_event({:READY, _payload, _ws_state}) do\n    Logger.info(\"Bot is READY - gateway connection established\")\n    :persistent_term.put(:soundboard_bot_ready, true)\n    []\n  end\n\n  def handle_event({:VOICE_READY, payload, _ws_state}) do\n    Logger.info(\"\"\"\n    Voice Ready Event:\n    Guild ID: #{payload.guild_id}\n    Channel ID: #{payload.channel_id}\n    \"\"\")\n\n    []\n  end\n\n  def handle_event({:VOICE_PLAYBACK_FINISHED, payload, _ws_state}) do\n    Soundboard.AudioPlayer.playback_finished(payload.guild_id)\n    []\n  end\n\n  def handle_event({:VOICE_SERVER_UPDATE, _payload, _ws_state}), do: []\n\n  def handle_event({:VOICE_CHANNEL_STATUS_UPDATE, _payload, _ws_state}), do: []\n\n  def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do\n    CommandHandler.handle_message(msg)\n    []\n  end\n\n  def handle_event(_event), do: []\n\n  @impl true\n  def handle_cast({:eda_event, event}, state) do\n    event\n    |> handle_event()\n    |> apply_runtime_actions()\n\n    {:noreply, state}\n  end\n\n  @impl true\n  def handle_info({:event, {event_name, payload, ws_state}}, state) do\n    {event_name, payload, ws_state}\n    |> handle_event()\n    |> apply_runtime_actions()\n\n    {:noreply, state}\n  end\n\n  def handle_info({:recheck_alone, guild_id, channel_id}, state) do\n    guild_id\n    |> VoiceRuntime.recheck_alone(channel_id)\n    |> apply_runtime_actions()\n\n    {:noreply, state}\n  end\n\n  def handle_info(_msg, state), do: {:noreply, state}\n\n  def get_current_voice_channel do\n    VoiceRuntime.get_current_voice_channel()\n  end\n\n  defp apply_runtime_actions(actions) when is_list(actions) do\n    Enum.each(actions, &apply_runtime_action/1)\n  end\n\n  defp apply_runtime_actions(_actions), do: :ok\n\n  defp apply_runtime_action({:schedule_recheck_alone, guild_id, channel_id, delay_ms}) do\n    Process.send_after(self(), {:recheck_alone, guild_id, channel_id}, delay_ms)\n  end\n\n  defp apply_runtime_action(_action), do: :ok\nend\n"
  },
  {
    "path": "lib/soundboard/discord/message.ex",
    "content": "defmodule Soundboard.Discord.Message do\n  @moduledoc false\n\n  alias EDA.API.Message, as: EDAMessage\n\n  def create(channel_id, payload) do\n    EDAMessage.create(to_id(channel_id), payload)\n  end\n\n  defp to_id(value) when is_integer(value), do: Integer.to_string(value)\n  defp to_id(value), do: to_string(value)\nend\n"
  },
  {
    "path": "lib/soundboard/discord/role_checker.ex",
    "content": "defmodule Soundboard.Discord.RoleChecker do\n  @moduledoc false\n  require Logger\n\n  alias EDA.API.Member\n\n  @doc \"\"\"\n  Check if the role-gated access feature is enabled.\n\n  Returns true only when both required_guild_id and required_role_ids are configured.\n  \"\"\"\n  def feature_enabled? do\n    guild_id = Application.get_env(:soundboard, :required_guild_id)\n    role_ids = Application.get_env(:soundboard, :required_role_ids, [])\n\n    not is_nil(guild_id) and Enum.any?(role_ids)\n  end\n\n  @doc \"\"\"\n  Check if a user is authorized to access the application.\n\n  Returns true if:\n  - The feature is disabled, OR\n  - The user's member object contains at least one of the required roles\n\n  Returns false if:\n  - The feature is enabled and the API call fails, OR\n  - The user has none of the required roles, OR\n  - The API response shape is unexpected\n  \"\"\"\n  def authorized?(user_id) do\n    if feature_enabled?() do\n      check_member_roles(user_id)\n    else\n      true\n    end\n  end\n\n  defp check_member_roles(user_id) do\n    guild_id = Application.get_env(:soundboard, :required_guild_id)\n\n    guild_id\n    |> Member.get(user_id)\n    |> member_authorized?(user_id)\n  end\n\n  defp member_authorized?({:ok, %{\"roles\" => roles}}, user_id) when is_list(roles) do\n    required_role_ids = Application.get_env(:soundboard, :required_role_ids, [])\n    authorized = Enum.any?(roles, &Enum.member?(required_role_ids, &1))\n\n    unless authorized do\n      Logger.info(\"Discord user #{user_id} has no matching required roles\")\n    end\n\n    authorized\n  end\n\n  defp member_authorized?({:ok, _member}, user_id) do\n    Logger.warning(\"Unexpected member response shape for Discord user #{user_id}\")\n    false\n  end\n\n  defp member_authorized?({:error, reason}, user_id) do\n    Logger.error(\"Member API error for Discord user #{user_id}: #{inspect(reason)}\")\n    false\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/runtime_capability.ex",
    "content": "defmodule Soundboard.Discord.RuntimeCapability do\n  @moduledoc false\n\n  require Logger\n\n  alias EDA.Voice.Dave.Native\n\n  def discord_handler_enabled? do\n    Application.get_env(:soundboard, :env) != :test and voice_runtime_available?()\n  end\n\n  def voice_runtime_available? do\n    match?(:ok, voice_runtime_status())\n  end\n\n  def voice_runtime_status do\n    cond do\n      Application.get_env(:soundboard, :env) == :test ->\n        :ok\n\n      not Application.get_env(:eda, :dave, false) ->\n        :ok\n\n      Native.available?() ->\n        :ok\n\n      true ->\n        {:degraded, :dave_unavailable}\n    end\n  end\n\n  def log_degraded_mode do\n    case voice_runtime_status() do\n      {:degraded, :dave_unavailable} ->\n        Logger.error(\"\"\"\n        Discord voice runtime is disabled because EDA DAVE is enabled but the native library is unavailable.\n        The web app will continue to boot, but Discord voice features stay offline until DAVE is packaged correctly\n        or EDA_DAVE=false is configured.\n        \"\"\")\n\n        :ok\n\n      _ ->\n        :ok\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/discord/voice.ex",
    "content": "defmodule Soundboard.Discord.Voice do\n  @moduledoc false\n\n  require Logger\n\n  alias EDA.Voice, as: EDAVoice\n\n  @connected_error \"Must be connected to voice channel to play audio.\"\n  @not_ready_error \"Voice session is still negotiating encryption.\"\n  @already_playing_error \"Audio already playing in voice channel.\"\n\n  def join_channel(guild_id, channel_id) do\n    voice_module().join(to_id(guild_id), to_id(channel_id))\n  end\n\n  def leave_channel(guild_id) do\n    voice_module().leave(to_id(guild_id))\n  end\n\n  def play(guild_id, input, type, opts \\\\ []) do\n    guild_id = to_id(guild_id)\n\n    case play_with_supported_arity(guild_id, input, type, opts) do\n      :ok -> :ok\n      {:error, :already_playing} -> {:error, @already_playing_error}\n      {:error, :not_connected} -> {:error, @connected_error}\n      {:error, :not_ready} -> {:error, @not_ready_error}\n      {:error, reason} -> {:error, inspect(reason)}\n    end\n  end\n\n  def stop(guild_id) do\n    voice_module().stop(to_id(guild_id))\n  end\n\n  def ready?(guild_id) do\n    voice_module().ready?(to_id(guild_id))\n  end\n\n  def channel_id(guild_id) do\n    voice_module().channel_id(to_id(guild_id))\n  end\n\n  def playing?(guild_id) do\n    voice_module().playing?(to_id(guild_id))\n  end\n\n  # Compatibility shape for existing RTP probe code.\n  def get_voice(guild_id) do\n    case voice_module().get_voice_state(to_id(guild_id)) do\n      {:ok, %{sequence: seq} = state} -> {:ok, %{rtp_sequence: seq, state: state}}\n      {:ok, state} -> {:ok, %{state: state}}\n      {:error, reason} -> {:error, reason}\n      other -> {:error, {:unexpected_voice_state, other}}\n    end\n  end\n\n  defp play_with_supported_arity(guild_id, input, type, opts) do\n    module = voice_module()\n\n    cond do\n      function_exported?(module, :play, 4) ->\n        :erlang.apply(module, :play, [guild_id, input, type, opts])\n\n      opts == [] ->\n        module.play(guild_id, input, type)\n\n      true ->\n        Logger.debug(\"EDA.Voice.play/4 unavailable; dropping playback opts #{inspect(opts)}\")\n        module.play(guild_id, input, type)\n    end\n  end\n\n  defp voice_module do\n    Application.get_env(:soundboard, :eda_voice_module, EDAVoice)\n  end\n\n  defp to_id(nil), do: nil\n  defp to_id(value) when is_integer(value), do: Integer.to_string(value)\n  defp to_id(value), do: to_string(value)\nend\n"
  },
  {
    "path": "lib/soundboard/favorites/favorite.ex",
    "content": "defmodule Soundboard.Favorites.Favorite do\n  @moduledoc \"\"\"\n  The Favorite module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Sound\n\n  schema \"favorites\" do\n    belongs_to :user, User\n    belongs_to :sound, Sound\n\n    timestamps()\n  end\n\n  def changeset(favorite, attrs) do\n    favorite\n    |> cast(attrs, [:user_id, :sound_id])\n    |> validate_required([:user_id, :sound_id])\n    |> foreign_key_constraint(:user_id)\n    |> foreign_key_constraint(:sound_id)\n    |> unique_constraint([:user_id, :sound_id])\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/favorites.ex",
    "content": "defmodule Soundboard.Favorites do\n  @moduledoc \"\"\"\n  The Favorites module.\n  \"\"\"\n\n  import Ecto.Query\n\n  alias Soundboard.{Favorites.Favorite, Repo, Sound}\n\n  @type favorite_result :: {:ok, Favorite.t()} | {:error, Ecto.Changeset.t()}\n\n  @max_favorites 16\n\n  @spec list_favorites(integer()) :: [integer()]\n  def list_favorites(user_id) do\n    Favorite\n    |> where([f], f.user_id == ^user_id)\n    |> select([f], f.sound_id)\n    |> Repo.all()\n  end\n\n  @spec list_favorite_sounds_with_tags(integer()) :: [Sound.t()]\n  def list_favorite_sounds_with_tags(user_id) do\n    favorite_ids_query =\n      Favorite\n      |> where([f], f.user_id == ^user_id)\n      |> select([f], f.sound_id)\n\n    Sound.with_tags()\n    |> where([s], s.id in subquery(favorite_ids_query))\n    |> order_by([s], asc: fragment(\"lower(?)\", s.filename))\n    |> Repo.all()\n  end\n\n  @spec toggle_favorite(integer(), integer()) :: favorite_result()\n  def toggle_favorite(user_id, sound_id) do\n    case Repo.get_by(Favorite, user_id: user_id, sound_id: sound_id) do\n      nil -> add_favorite(user_id, sound_id)\n      favorite -> Repo.delete(favorite)\n    end\n  end\n\n  @spec error_message(Ecto.Changeset.t()) :: String.t()\n  def error_message(%Ecto.Changeset{} = changeset) do\n    Enum.map_join(changeset.errors, \", \", fn\n      {:base, {msg, _}} -> msg\n      {:sound, {\"does not exist\", _}} -> \"Sound does not exist\"\n      {field, {msg, _}} -> \"#{field} #{msg}\"\n    end)\n  end\n\n  defp add_favorite(user_id, sound_id) do\n    case Repo.get(Sound, sound_id) do\n      nil ->\n        {:error,\n         Ecto.Changeset.add_error(Ecto.Changeset.change(%Favorite{}), :sound, \"does not exist\")}\n\n      _sound ->\n        # Check if user has reached max favorites\n        count = Repo.one(from f in Favorite, where: f.user_id == ^user_id, select: count())\n\n        if count >= @max_favorites do\n          {:error,\n           Ecto.Changeset.add_error(\n             Ecto.Changeset.change(%Favorite{}),\n             :base,\n             \"You can only have #{@max_favorites} favorites\"\n           )}\n        else\n          %Favorite{}\n          |> Favorite.changeset(%{user_id: user_id, sound_id: sound_id})\n          |> Repo.insert()\n        end\n    end\n  end\n\n  @spec favorite?(integer(), integer()) :: boolean()\n  def favorite?(user_id, sound_id) do\n    Repo.exists?(from f in Favorite, where: f.user_id == ^user_id and f.sound_id == ^sound_id)\n  end\n\n  @spec max_favorites() :: pos_integer()\n  def max_favorites, do: @max_favorites\nend\n"
  },
  {
    "path": "lib/soundboard/public_url.ex",
    "content": "defmodule Soundboard.PublicURL do\n  @moduledoc \"\"\"\n  Shared helper for the application's externally visible base URL.\n\n  Web and Discord-facing features use this so URL generation follows one\n  application-level contract instead of reaching into endpoint config details in\n  multiple places.\n  \"\"\"\n\n  def current, do: SoundboardWeb.Endpoint.url()\n\n  def from_uri_or_current(nil), do: current()\n\n  def from_uri_or_current(uri) do\n    case URI.parse(uri) do\n      %URI{scheme: scheme, host: host, port: port} when is_binary(scheme) and is_binary(host) ->\n        scheme <> \"://\" <> host <> port_suffix(scheme, port)\n\n      _ ->\n        current()\n    end\n  end\n\n  defp port_suffix(\"http\", 80), do: \"\"\n  defp port_suffix(\"https\", 443), do: \"\"\n  defp port_suffix(_scheme, nil), do: \"\"\n  defp port_suffix(_scheme, port), do: \":#{port}\"\nend\n"
  },
  {
    "path": "lib/soundboard/pubsub_topics.ex",
    "content": "defmodule Soundboard.PubSubTopics do\n  @moduledoc false\n\n  alias Phoenix.PubSub\n\n  @files_topic \"soundboard.files\"\n  @playback_topic \"soundboard.playback\"\n  @stats_topic \"soundboard.stats\"\n\n  def files_topic, do: @files_topic\n  def playback_topic, do: @playback_topic\n  def stats_topic, do: @stats_topic\n\n  def subscribe_files, do: PubSub.subscribe(Soundboard.PubSub, @files_topic)\n  def subscribe_playback, do: PubSub.subscribe(Soundboard.PubSub, @playback_topic)\n  def subscribe_stats, do: PubSub.subscribe(Soundboard.PubSub, @stats_topic)\n\n  def broadcast_files_updated do\n    PubSub.broadcast(Soundboard.PubSub, @files_topic, {:files_updated})\n  end\n\n  def broadcast_stats_updated do\n    PubSub.broadcast(Soundboard.PubSub, @stats_topic, {:stats_updated})\n  end\n\n  def broadcast_sound_played(sound_name, username) do\n    PubSub.broadcast(\n      Soundboard.PubSub,\n      @playback_topic,\n      {:sound_played, %{filename: sound_name, played_by: username}}\n    )\n  end\n\n  def broadcast_error(message) do\n    PubSub.broadcast(Soundboard.PubSub, @playback_topic, {:error, message})\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/release.ex",
    "content": "defmodule Soundboard.Release do\n  @moduledoc false\n\n  @app :soundboard\n\n  def migrate do\n    load_app()\n\n    for repo <- repos() do\n      {:ok, _, _} =\n        Ecto.Migrator.with_repo(repo, fn repo_ref ->\n          Ecto.Migrator.run(repo_ref, :up, all: true)\n        end)\n    end\n  end\n\n  def rollback(repo, version) do\n    load_app()\n\n    {:ok, _, _} =\n      Ecto.Migrator.with_repo(repo, fn repo_ref ->\n        Ecto.Migrator.run(repo_ref, :down, to: version)\n      end)\n  end\n\n  defp repos do\n    Application.fetch_env!(@app, :ecto_repos)\n  end\n\n  defp load_app do\n    Application.load(@app)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/repo.ex",
    "content": "defmodule Soundboard.Repo do\n  use Ecto.Repo,\n    otp_app: :soundboard,\n    adapter: Ecto.Adapters.SQLite3\nend\n"
  },
  {
    "path": "lib/soundboard/sound.ex",
    "content": "defmodule Soundboard.Sound do\n  @moduledoc \"\"\"\n  Sound schema.\n  \"\"\"\n\n  use Ecto.Schema\n  import Ecto.Changeset\n  import Ecto.Query\n\n  @type t :: %__MODULE__{}\n\n  @spec changeset(t(), map()) :: Ecto.Changeset.t()\n  @spec with_tags(Ecto.Queryable.t()) :: Ecto.Query.t()\n  @spec by_tag(Ecto.Queryable.t(), String.t()) :: Ecto.Query.t()\n\n  schema \"sounds\" do\n    field :filename, :string\n    field :url, :string\n    field :source_type, :string, default: \"local\"\n    field :description, :string\n    field :volume, :float, default: 1.0\n    belongs_to :user, Soundboard.Accounts.User\n    has_many :user_sound_settings, Soundboard.UserSoundSetting\n\n    many_to_many :tags, Soundboard.Tag,\n      join_through: Soundboard.SoundTag,\n      on_replace: :delete,\n      unique: true\n\n    timestamps()\n  end\n\n  def changeset(sound, attrs) do\n    sound\n    |> cast(attrs, [\n      :filename,\n      :url,\n      :source_type,\n      :description,\n      :user_id,\n      :volume\n    ])\n    |> validate_required([:user_id])\n    |> validate_source_type()\n    |> validate_volume()\n    |> unique_constraint(:filename, name: :sounds_filename_index)\n    |> put_tags(attrs)\n  end\n\n  def with_tags(query \\\\ __MODULE__) do\n    from s in query,\n      preload: [:tags]\n  end\n\n  def by_tag(query \\\\ __MODULE__, tag_name) do\n    from s in query,\n      join: t in assoc(s, :tags),\n      where: t.name == ^tag_name\n  end\n\n  defp validate_source_type(changeset) do\n    case get_field(changeset, :source_type) do\n      \"local\" -> validate_required(changeset, [:filename])\n      \"url\" -> validate_required(changeset, [:url])\n      _ -> add_error(changeset, :source_type, \"must be either 'local' or 'url'\")\n    end\n  end\n\n  defp put_tags(changeset, %{tags: tags}) when is_list(tags) do\n    put_assoc(changeset, :tags, tags)\n  end\n\n  defp put_tags(changeset, _), do: changeset\n\n  defp validate_volume(changeset) do\n    changeset\n    |> validate_number(:volume,\n      greater_than_or_equal_to: 0.0,\n      less_than_or_equal_to: 1.5\n    )\n    |> case do\n      %{changes: %{volume: volume}} = cs when is_nil(volume) ->\n        put_change(cs, :volume, 1.0)\n\n      cs ->\n        cs\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sound_tag.ex",
    "content": "defmodule Soundboard.SoundTag do\n  @moduledoc \"\"\"\n  The SoundTag module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n\n  @primary_key false\n  schema \"sound_tags\" do\n    belongs_to :sound, Soundboard.Sound, primary_key: true\n    belongs_to :tag, Soundboard.Tag, primary_key: true\n    timestamps()\n  end\n\n  def changeset(sound_tag, attrs) do\n    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n    sound_tag\n    |> cast(attrs, [:sound_id, :tag_id])\n    |> validate_required([:sound_id, :tag_id])\n    |> unique_constraint([:sound_id, :tag_id])\n    |> put_change(:inserted_at, now)\n    |> put_change(:updated_at, now)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/management.ex",
    "content": "defmodule Soundboard.Sounds.Management do\n  @moduledoc \"\"\"\n  Domain-level sound update/delete operations used by LiveViews.\n\n  Sound metadata edits are collaborative for signed-in users, while deletion\n  remains restricted to the original uploader. Per-user join/leave preferences\n  are stored separately so editors keep their own settings without taking over\n  sound ownership.\n  \"\"\"\n\n  alias Soundboard.{AudioPlayer, Repo, Sound, UploadsPath, Volume}\n  require Logger\n\n  def update_sound(%Sound{} = sound, user_id, params) do\n    Repo.transaction(fn ->\n      db_sound =\n        Repo.get!(Sound, sound.id)\n        |> Repo.preload(:user_sound_settings)\n\n      old_path = UploadsPath.file_path(db_sound.filename)\n      new_filename = params[\"filename\"] <> Path.extname(db_sound.filename)\n      new_path = UploadsPath.file_path(new_filename)\n\n      sound_params = %{\n        filename: new_filename,\n        source_type: params[\"source_type\"] || db_sound.source_type,\n        url: params[\"url\"],\n        user_id: db_sound.user_id || user_id,\n        volume:\n          params[\"volume\"]\n          |> Volume.percent_to_decimal(Volume.decimal_to_percent(db_sound.volume))\n      }\n\n      updated_sound =\n        case Sound.changeset(db_sound, sound_params) |> Repo.update() do\n          {:ok, updated_sound} ->\n            updated_sound = update_user_settings(db_sound, user_id, updated_sound, params)\n            AudioPlayer.invalidate_cache(db_sound.filename)\n            AudioPlayer.invalidate_cache(updated_sound.filename)\n            updated_sound\n\n          {:error, changeset} ->\n            Repo.rollback(changeset)\n        end\n\n      case maybe_rename_local_file(db_sound, old_path, new_path) do\n        :ok -> updated_sound\n        {:error, error} -> Repo.rollback(error)\n      end\n    end)\n  end\n\n  def delete_sound(%Sound{} = sound, user_id) do\n    db_sound = Repo.get!(Sound, sound.id)\n\n    with true <- db_sound.user_id == user_id,\n         {:ok, _deleted_sound} <- Repo.delete(db_sound) do\n      AudioPlayer.invalidate_cache(db_sound.filename)\n      maybe_remove_local_file(db_sound)\n      :ok\n    else\n      false -> {:error, :forbidden}\n      {:error, changeset} -> {:error, changeset}\n    end\n  end\n\n  defp maybe_remove_local_file(%{source_type: \"local\", filename: filename}) do\n    _ = File.rm(UploadsPath.file_path(filename))\n    :ok\n  end\n\n  defp maybe_remove_local_file(_), do: :ok\n\n  defp maybe_rename_local_file(%{source_type: \"local\"} = sound, old_path, new_path) do\n    cond do\n      sound.filename == Path.basename(new_path) ->\n        :ok\n\n      old_path == new_path ->\n        :ok\n\n      not File.exists?(old_path) ->\n        Logger.error(\"Source file not found: #{old_path}\")\n        {:error, \"Source file not found\"}\n\n      true ->\n        case File.rename(old_path, new_path) do\n          :ok ->\n            :ok\n\n          {:error, reason} ->\n            Logger.error(\"File rename failed: #{inspect(reason)}\")\n            {:error, \"Failed to rename file: #{inspect(reason)}\"}\n        end\n    end\n  end\n\n  defp maybe_rename_local_file(_, _, _), do: :ok\n\n  defp update_user_settings(sound, user_id, updated_sound, params) do\n    user_setting =\n      Enum.find(sound.user_sound_settings, &(&1.user_id == user_id)) ||\n        %Soundboard.UserSoundSetting{sound_id: sound.id, user_id: user_id}\n\n    setting_params = %{\n      user_id: user_id,\n      sound_id: sound.id,\n      is_join_sound: params[\"is_join_sound\"] == \"true\",\n      is_leave_sound: params[\"is_leave_sound\"] == \"true\"\n    }\n\n    Soundboard.UserSoundSetting.clear_conflicting_settings(\n      user_id,\n      sound.id,\n      setting_params.is_join_sound,\n      setting_params.is_leave_sound\n    )\n\n    case user_setting\n         |> Soundboard.UserSoundSetting.changeset(setting_params)\n         |> Repo.insert_or_update() do\n      {:ok, _setting} ->\n        updated_sound\n\n      {:error, changeset} ->\n        Logger.error(\"Failed to update user settings: #{inspect(changeset)}\")\n        Repo.rollback(changeset)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/tags.ex",
    "content": "defmodule Soundboard.Sounds.Tags do\n  @moduledoc \"\"\"\n  Domain helpers for searching, resolving, and persisting sound tags.\n  \"\"\"\n\n  import Ecto.Changeset\n\n  alias Soundboard.{Repo, Sound, Tag}\n\n  def search(query) do\n    Tag.search(query)\n    |> Repo.all()\n  end\n\n  def all_for_sounds(sounds) do\n    sounds\n    |> Enum.flat_map(& &1.tags)\n    |> Enum.uniq_by(& &1.id)\n    |> Enum.sort_by(& &1.name)\n  end\n\n  def count_sounds_with_tag(sounds, tag) do\n    Enum.count(sounds, fn sound ->\n      Enum.any?(sound.tags, &(&1.id == tag.id))\n    end)\n  end\n\n  def tag_selected?(tag, selected_tags) do\n    Enum.any?(selected_tags, &(&1.id == tag.id))\n  end\n\n  def update_sound_tags(sound, tags) do\n    sound\n    |> Repo.preload(:tags)\n    |> Sound.changeset(%{tags: tags})\n    |> Repo.update()\n  end\n\n  def resolve_many(tags) when is_list(tags) do\n    tags\n    |> Enum.uniq()\n    |> Enum.reduce_while({:ok, []}, fn tag, {:ok, acc} ->\n      case resolve(tag) do\n        {:ok, nil} -> {:cont, {:ok, acc}}\n        {:ok, resolved_tag} -> {:cont, {:ok, [resolved_tag | acc]}}\n        {:error, reason} -> {:halt, {:error, reason}}\n      end\n    end)\n    |> case do\n      {:ok, tag_list} -> {:ok, Enum.reverse(tag_list) |> Enum.uniq_by(& &1.id)}\n      error -> error\n    end\n  end\n\n  def resolve_many(_), do: {:ok, []}\n\n  def resolve(%Tag{} = tag), do: {:ok, tag}\n\n  def resolve(tag_name) when is_binary(tag_name) do\n    normalized =\n      tag_name\n      |> String.trim()\n      |> String.downcase()\n\n    if normalized == \"\" do\n      {:error, add_error(change(%Sound{}), :tags, \"can't be blank\")}\n    else\n      find_or_create(normalized)\n    end\n  end\n\n  def resolve(_), do: {:ok, nil}\n\n  def find_or_create(name) when is_binary(name) do\n    normalized = name |> String.trim() |> String.downcase()\n\n    case Repo.get_by(Tag, name: normalized) do\n      %Tag{} = tag -> {:ok, tag}\n      nil -> insert_or_get(normalized)\n    end\n  end\n\n  def list_for_sound(filename) do\n    case Repo.get_by(Sound, filename: filename) do\n      nil -> []\n      sound -> sound |> Repo.preload(:tags) |> Map.get(:tags)\n    end\n  end\n\n  defp insert_or_get(name) do\n    case %Tag{} |> Tag.changeset(%{name: name}) |> Repo.insert() do\n      {:ok, tag} -> {:ok, tag}\n      {:error, _} -> fetch_after_insert_conflict(name)\n    end\n  end\n\n  defp fetch_after_insert_conflict(name) do\n    case Repo.get_by(Tag, name: name) do\n      %Tag{} = tag -> {:ok, tag}\n      nil -> {:error, add_error(change(%Sound{}), :tags, \"is invalid\")}\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/uploads/create_request.ex",
    "content": "defmodule Soundboard.Sounds.Uploads.CreateRequest do\n  @moduledoc false\n\n  alias Soundboard.Accounts.User\n\n  @enforce_keys [:user]\n  defstruct [\n    :user,\n    :source_type,\n    :name,\n    :url,\n    :upload,\n    :tags,\n    :volume,\n    :is_join_sound,\n    :is_leave_sound,\n    :default_volume_percent\n  ]\n\n  @type upload ::\n          %Plug.Upload{}\n          | %{\n              optional(:path) => String.t(),\n              optional(:filename) => String.t(),\n              optional(:client_name) => String.t(),\n              optional(String.t()) => String.t()\n            }\n\n  @type t :: %__MODULE__{\n          user: User.t() | nil,\n          source_type: String.t() | nil,\n          name: String.t() | nil,\n          url: String.t() | nil,\n          upload: upload() | nil,\n          tags: [map() | String.t()] | nil,\n          volume: String.t() | number() | nil,\n          is_join_sound: boolean() | String.t() | nil,\n          is_leave_sound: boolean() | String.t() | nil,\n          default_volume_percent: String.t() | number() | nil\n        }\n\n  @spec new(User.t() | nil, map()) :: t()\n  def new(user, attrs \\\\ %{}) when is_map(attrs) do\n    %__MODULE__{\n      user: user,\n      source_type: get_param(attrs, :source_type),\n      name: get_param(attrs, :name),\n      url: get_param(attrs, :url),\n      upload: normalize_upload(get_param(attrs, :upload) || get_param(attrs, :file)),\n      tags: get_param(attrs, :tags) || get_param(attrs, \"tags[]\") || [],\n      volume: get_param(attrs, :volume),\n      is_join_sound: get_param(attrs, :is_join_sound),\n      is_leave_sound: get_param(attrs, :is_leave_sound),\n      default_volume_percent: get_param(attrs, :default_volume_percent)\n    }\n  end\n\n  @spec put_upload(t(), upload() | nil) :: t()\n  def put_upload(%__MODULE__{} = request, upload) do\n    struct!(request, upload: normalize_upload(upload))\n  end\n\n  defp normalize_upload(nil), do: nil\n\n  defp normalize_upload(upload) when is_map(upload) do\n    %{\n      path: get_param(upload, :path),\n      filename: get_param(upload, :filename) || get_param(upload, :client_name)\n    }\n  end\n\n  defp normalize_upload(_), do: nil\n\n  defp get_param(map, key, default \\\\ nil) do\n    Map.get(map, key, Map.get(map, to_string(key), default))\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/uploads/creator.ex",
    "content": "defmodule Soundboard.Sounds.Uploads.Creator do\n  @moduledoc false\n\n  alias Soundboard.{PubSubTopics, Repo, Sound, Stats, UserSoundSetting}\n  alias Soundboard.Sounds.Tags\n  alias Soundboard.Sounds.Uploads.Source\n\n  @spec create(map(), map()) :: {:ok, Sound.t()} | {:error, term()}\n  def create(params, source) do\n    Repo.transaction(fn ->\n      with {:ok, tags} <- Tags.resolve_many(params.tags),\n           {:ok, sound} <- insert_sound(params, source, tags),\n           {:ok, _setting} <- insert_user_setting(sound, params),\n           sound <- Repo.preload(sound, [:tags, :user, :user_sound_settings]) do\n        sound\n      else\n        {:error, reason} -> Repo.rollback(reason)\n      end\n    end)\n    |> case do\n      {:ok, sound} ->\n        broadcast_updates()\n        {:ok, sound}\n\n      {:error, reason} ->\n        Source.cleanup_local_file(source.copied_file_path)\n        {:error, reason}\n    end\n  end\n\n  defp insert_sound(params, source, tags) do\n    sound_attrs = %{\n      filename: source.filename,\n      source_type: source.source_type,\n      url: source.url,\n      user_id: params.user.id,\n      volume: params.volume,\n      tags: tags\n    }\n\n    %Sound{}\n    |> Sound.changeset(sound_attrs)\n    |> Repo.insert()\n  end\n\n  defp insert_user_setting(sound, params) do\n    attrs = %{\n      user_id: params.user.id,\n      sound_id: sound.id,\n      is_join_sound: params.is_join_sound,\n      is_leave_sound: params.is_leave_sound\n    }\n\n    UserSoundSetting.clear_conflicting_settings(\n      params.user.id,\n      sound.id,\n      params.is_join_sound,\n      params.is_leave_sound\n    )\n\n    %UserSoundSetting{}\n    |> UserSoundSetting.changeset(attrs)\n    |> Repo.insert()\n  end\n\n  defp broadcast_updates do\n    PubSubTopics.broadcast_files_updated()\n    Stats.broadcast_stats_update()\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/uploads/normalizer.ex",
    "content": "defmodule Soundboard.Sounds.Uploads.Normalizer do\n  @moduledoc false\n\n  import Ecto.Changeset\n\n  alias Soundboard.{Sound, Volume}\n  alias Soundboard.Sounds.Uploads.CreateRequest\n\n  @spec normalize(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}\n  def normalize(%CreateRequest{} = request) do\n    case request.user do\n      %Soundboard.Accounts.User{} = user ->\n        source_type = normalize_source_type(request.source_type, request.upload, request.url)\n        name = normalize_name(request.name)\n\n        build_normalized_params(%{\n          user: user,\n          source_type: source_type,\n          name: name,\n          url: normalize_url(request.url),\n          tags: request.tags,\n          volume: request.volume,\n          is_join_sound: request.is_join_sound,\n          is_leave_sound: request.is_leave_sound,\n          default_volume_percent: request.default_volume_percent || 100,\n          upload: request.upload\n        })\n\n      _ ->\n        {:error, add_error(change(%Sound{}), :user_id, \"can't be blank\")}\n    end\n  end\n\n  defp build_normalized_params(%{\n         user: %Soundboard.Accounts.User{} = user,\n         source_type: source_type,\n         name: name,\n         url: url,\n         tags: tags,\n         volume: volume,\n         is_join_sound: is_join_sound,\n         is_leave_sound: is_leave_sound,\n         default_volume_percent: default_volume_percent,\n         upload: upload\n       }) do\n    if blank?(name) do\n      {:error, add_error(change(%Sound{}), :filename, \"can't be blank\")}\n    else\n      {:ok,\n       %{\n         user: user,\n         source_type: source_type,\n         name: name,\n         url: url,\n         tags: normalize_tags(tags),\n         volume:\n           Volume.percent_to_decimal(volume, normalize_default_volume(default_volume_percent)),\n         is_join_sound: to_boolean(is_join_sound),\n         is_leave_sound: to_boolean(is_leave_sound),\n         upload: upload\n       }}\n    end\n  end\n\n  defp build_normalized_params(_params) do\n    {:error, add_error(change(%Sound{}), :user_id, \"can't be blank\")}\n  end\n\n  defp normalize_default_volume(value), do: Volume.normalize_percent(value, 100)\n\n  defp normalize_tags(nil), do: []\n\n  defp normalize_tags(tags) when is_binary(tags) do\n    tags\n    |> String.split(\",\", trim: true)\n    |> Enum.map(&String.trim/1)\n    |> Enum.reject(&(&1 == \"\"))\n  end\n\n  defp normalize_tags(tags) when is_list(tags), do: tags\n  defp normalize_tags(_), do: []\n\n  defp normalize_source_type(source_type, upload, url) when is_binary(source_type) do\n    case source_type |> String.trim() |> String.downcase() do\n      \"local\" -> \"local\"\n      \"url\" -> \"url\"\n      _ -> infer_source_type(upload, url)\n    end\n  end\n\n  defp normalize_source_type(_source_type, upload, url), do: infer_source_type(upload, url)\n\n  defp infer_source_type(upload, url) do\n    cond do\n      is_map(upload) -> \"local\"\n      is_binary(url) and String.trim(url) != \"\" -> \"url\"\n      true -> \"local\"\n    end\n  end\n\n  defp normalize_name(name) when is_binary(name), do: String.trim(name)\n  defp normalize_name(_), do: nil\n\n  defp normalize_url(url) when is_binary(url), do: String.trim(url)\n  defp normalize_url(_), do: nil\n\n  defp to_boolean(value) when value in [true, \"true\", \"1\", 1, \"on\", \"yes\"], do: true\n  defp to_boolean(_), do: false\n\n  defp blank?(value), do: value in [nil, \"\"]\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/uploads/source.ex",
    "content": "defmodule Soundboard.Sounds.Uploads.Source do\n  @moduledoc false\n\n  import Ecto.Changeset\n  import Ecto.Query\n\n  require Logger\n\n  alias Soundboard.{Repo, Sound, UploadsPath}\n\n  @allowed_extensions ~w(.mp3 .wav .ogg .m4a)\n\n  @spec prepare(map(), :validate | :create) :: {:ok, map()} | {:error, Ecto.Changeset.t()}\n  def prepare(%{source_type: \"url\"} = params, _mode) do\n    with {:ok, url} <- validate_url(params.url),\n         filename <- params.name <> url_file_extension(url),\n         :ok <- validate_destination_filename(filename) do\n      {:ok,\n       %{\n         filename: filename,\n         source_type: \"url\",\n         url: url,\n         copied_file_path: nil\n       }}\n    end\n  end\n\n  def prepare(%{source_type: \"local\"} = params, :validate) do\n    with {:ok, upload} <- validate_local_upload(params.upload, :validate),\n         {:ok, ext} <- validate_local_extension(upload.filename),\n         filename <- params.name <> ext,\n         :ok <- validate_destination_filename(filename) do\n      {:ok,\n       %{\n         filename: filename,\n         source_type: \"local\",\n         url: nil,\n         copied_file_path: nil\n       }}\n    end\n  end\n\n  def prepare(%{source_type: \"local\"} = params, :create) do\n    with {:ok, upload} <- validate_local_upload(params.upload, :create),\n         {:ok, ext} <- validate_local_extension(upload.filename),\n         filename <- params.name <> ext,\n         :ok <- validate_destination_filename(filename),\n         {:ok, copied_file_path} <- copy_local_file(upload.path, filename) do\n      {:ok,\n       %{\n         filename: filename,\n         source_type: \"local\",\n         url: nil,\n         copied_file_path: copied_file_path\n       }}\n    end\n  end\n\n  def prepare(_params, _mode) do\n    {:error, add_error(change(%Sound{}), :source_type, \"must be either 'local' or 'url'\")}\n  end\n\n  @spec cleanup_local_file(String.t() | nil) :: :ok\n  def cleanup_local_file(path) when is_binary(path) do\n    case File.rm(path) do\n      :ok ->\n        :ok\n\n      {:error, reason} ->\n        Logger.warning(\"Failed to clean up copied upload #{path}: #{inspect(reason)}\")\n        :ok\n    end\n  end\n\n  def cleanup_local_file(_path), do: :ok\n\n  defp validate_url(url) when is_binary(url) do\n    if blank?(url) do\n      {:error, add_error(change(%Sound{}), :url, \"can't be blank\")}\n    else\n      {:ok, url}\n    end\n  end\n\n  defp validate_url(_url), do: {:error, add_error(change(%Sound{}), :url, \"can't be blank\")}\n\n  defp validate_local_upload(nil, _mode),\n    do: {:error, add_error(change(%Sound{}), :file, \"Please select a file\")}\n\n  defp validate_local_upload(%{filename: filename} = upload, :validate) do\n    if blank?(filename) do\n      {:error, add_error(change(%Sound{}), :file, \"Please select a file\")}\n    else\n      {:ok, %{path: Map.get(upload, :path), filename: filename}}\n    end\n  end\n\n  defp validate_local_upload(%{path: path, filename: filename}, :create) when is_binary(path) do\n    if blank?(filename) do\n      {:error, add_error(change(%Sound{}), :file, \"Invalid file upload\")}\n    else\n      {:ok, %{path: path, filename: filename}}\n    end\n  end\n\n  defp validate_local_upload(_, _mode),\n    do: {:error, add_error(change(%Sound{}), :file, \"Please select a file\")}\n\n  defp validate_local_extension(filename) do\n    ext = filename |> Path.extname() |> String.downcase()\n\n    if ext in @allowed_extensions do\n      {:ok, ext}\n    else\n      {:error,\n       add_error(\n         change(%Sound{}),\n         :file,\n         \"Invalid file type. Please upload an MP3, WAV, OGG, or M4A file.\"\n       )}\n    end\n  end\n\n  defp copy_local_file(src_path, filename) do\n    uploads_dir = UploadsPath.dir()\n    dest_path = UploadsPath.file_path(filename)\n\n    with :ok <- ensure_uploads_dir(uploads_dir),\n         :ok <- File.cp(src_path, dest_path) do\n      {:ok, dest_path}\n    else\n      {:error, _reason} ->\n        {:error, add_error(change(%Sound{}), :file, \"Error saving file\")}\n    end\n  end\n\n  defp ensure_uploads_dir(uploads_dir) do\n    case File.mkdir_p(uploads_dir) do\n      :ok -> :ok\n      {:error, _reason} -> {:error, add_error(change(%Sound{}), :file, \"Error saving file\")}\n    end\n  end\n\n  defp validate_destination_filename(filename) do\n    dest_path = UploadsPath.file_path(filename)\n\n    if filename_taken?(filename) or File.exists?(dest_path) do\n      {:error, add_error(change(%Sound{}), :filename, \"has already been taken\")}\n    else\n      :ok\n    end\n  end\n\n  defp filename_taken?(filename) do\n    from(s in Sound, where: s.filename == ^filename)\n    |> Repo.exists?()\n  end\n\n  defp url_file_extension(url) when is_binary(url) do\n    ext =\n      url\n      |> URI.parse()\n      |> Map.get(:path)\n      |> case do\n        nil -> \"\"\n        path -> String.downcase(Path.extname(path || \"\"))\n      end\n\n    if ext in @allowed_extensions, do: ext, else: \"\"\n  end\n\n  defp url_file_extension(_), do: \"\"\n\n  defp blank?(value), do: value in [nil, \"\"]\nend\n"
  },
  {
    "path": "lib/soundboard/sounds/uploads.ex",
    "content": "defmodule Soundboard.Sounds.Uploads do\n  @moduledoc \"\"\"\n  Canonical sound upload/create API.\n  \"\"\"\n\n  import Ecto.Changeset\n\n  alias Soundboard.Sound\n  alias Soundboard.Sounds.Uploads.{CreateRequest, Creator, Normalizer, Source}\n\n  @type create_error :: Ecto.Changeset.t()\n  @type create_result :: {:ok, Sound.t()} | {:error, create_error()}\n\n  @spec validate(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}\n  def validate(%CreateRequest{} = request) do\n    with {:ok, params} <- Normalizer.normalize(request),\n         {:ok, _source} <- Source.prepare(params, :validate) do\n      {:ok, params}\n    end\n  end\n\n  @spec create(CreateRequest.t()) :: create_result()\n  def create(%CreateRequest{} = request) do\n    with {:ok, params} <- Normalizer.normalize(request),\n         {:ok, source} <- Source.prepare(params, :create),\n         {:ok, sound} <- Creator.create(params, source) do\n      {:ok, sound}\n    else\n      {:error, reason} -> {:error, normalize_create_error(reason)}\n    end\n  end\n\n  @spec error_message(Ecto.Changeset.t() | String.t() | term()) :: String.t()\n  def error_message(%Ecto.Changeset{} = changeset) do\n    Enum.map_join(changeset.errors, \", \", fn\n      {:filename, {\"has already been taken\", _}} -> \"A sound with that name already exists\"\n      {:file, {\"Please select a file\", _}} -> \"Please select a file\"\n      {key, {msg, _}} -> \"#{key} #{msg}\"\n    end)\n  end\n\n  def error_message(error) when is_binary(error), do: error\n  def error_message(_), do: \"An unexpected error occurred\"\n\n  defp normalize_create_error(%Ecto.Changeset{} = changeset), do: changeset\n  defp normalize_create_error(message) when is_binary(message), do: add_base_error(message)\n  defp normalize_create_error(_reason), do: add_base_error(\"An unexpected error occurred\")\n\n  defp add_base_error(message) do\n    change(%Sound{})\n    |> add_error(:base, message)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/sounds.ex",
    "content": "defmodule Soundboard.Sounds do\n  @moduledoc \"\"\"\n  Sound domain context.\n  \"\"\"\n\n  import Ecto.Query\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound}\n  alias Soundboard.Sounds.{Management, Uploads}\n  alias Soundboard.Sounds.Uploads.CreateRequest\n\n  @detailed_preloads [\n    :tags,\n    :user,\n    user_sound_settings: [user: []]\n  ]\n\n  @spec list_files() :: [Sound.t()]\n  def list_files do\n    Sound\n    |> Sound.with_tags()\n    |> preload(:user_sound_settings)\n    |> Repo.all()\n  end\n\n  @spec list_detailed() :: [Sound.t()]\n  def list_detailed do\n    Sound\n    |> Repo.all()\n    |> Repo.preload(@detailed_preloads)\n    |> Enum.sort_by(&String.downcase(&1.filename))\n  end\n\n  @spec fetch_sound_id(String.t()) :: {:ok, integer()} | :error\n  def fetch_sound_id(filename) when is_binary(filename) do\n    case Repo.get_by(Sound, filename: filename) do\n      nil -> :error\n      sound -> {:ok, sound.id}\n    end\n  end\n\n  def ids_by_filename([]), do: %{}\n\n  @spec ids_by_filename([String.t()]) :: %{optional(String.t()) => integer()}\n  def ids_by_filename(filenames) when is_list(filenames) do\n    from(s in Sound, where: s.filename in ^filenames, select: {s.filename, s.id})\n    |> Repo.all()\n    |> Map.new()\n  end\n\n  @spec filename_taken?(String.t()) :: boolean()\n  def filename_taken?(filename) when is_binary(filename) do\n    Repo.exists?(from s in Sound, where: s.filename == ^filename)\n  end\n\n  @spec filename_taken_excluding?(String.t(), integer() | String.t()) :: boolean()\n  def filename_taken_excluding?(filename, sound_id) do\n    from(s in Sound, where: s.filename == ^filename and s.id != ^sound_id)\n    |> Repo.exists?()\n  end\n\n  @spec filename_conflicts_across_extensions?(String.t(), [String.t()]) :: boolean()\n  def filename_conflicts_across_extensions?(base_name, extensions) when is_list(extensions) do\n    names = Enum.map(extensions, &(base_name <> &1))\n\n    from(s in Sound, where: s.filename in ^names)\n    |> Repo.exists?()\n  end\n\n  @spec fetch_filename_extension(term()) :: {:ok, String.t()} | :error\n  def fetch_filename_extension(sound_id) do\n    case Repo.get(Sound, sound_id) do\n      %Sound{filename: filename} -> {:ok, Path.extname(filename)}\n      _ -> :error\n    end\n  end\n\n  @spec get_recent_uploads(keyword()) :: [{String.t(), String.t(), NaiveDateTime.t()}]\n  def get_recent_uploads(opts \\\\ []) do\n    limit = Keyword.get(opts, :limit, 10)\n\n    from(s in Sound,\n      join: u in User,\n      on: s.user_id == u.id,\n      select: {s.filename, u.username, s.inserted_at},\n      order_by: [desc: s.inserted_at],\n      limit: ^limit\n    )\n    |> Repo.all()\n  end\n\n  @spec get_user_join_sound(integer()) :: String.t() | nil\n  def get_user_join_sound(user_id) do\n    Repo.one(\n      from uss in Soundboard.UserSoundSetting,\n        join: s in Sound,\n        on: uss.sound_id == s.id,\n        where: uss.user_id == ^user_id and uss.is_join_sound == true,\n        select: s.filename\n    )\n  end\n\n  @spec get_user_leave_sound(integer()) :: String.t() | nil\n  def get_user_leave_sound(user_id) do\n    Repo.one(\n      from uss in Soundboard.UserSoundSetting,\n        join: s in Sound,\n        on: uss.sound_id == s.id,\n        where: uss.user_id == ^user_id and uss.is_leave_sound == true,\n        select: s.filename\n    )\n  end\n\n  @spec get_user_join_sound_by_discord_id(term()) :: String.t() | nil\n  def get_user_join_sound_by_discord_id(discord_id) do\n    Repo.one(\n      from u in User,\n        where: u.discord_id == ^to_string(discord_id),\n        left_join: uss in Soundboard.UserSoundSetting,\n        on: uss.user_id == u.id and uss.is_join_sound == true,\n        left_join: s in Sound,\n        on: s.id == uss.sound_id,\n        select: s.filename,\n        limit: 1\n    )\n  end\n\n  @spec get_user_leave_sound_by_discord_id(term()) :: String.t() | nil\n  def get_user_leave_sound_by_discord_id(discord_id) do\n    Repo.one(\n      from u in User,\n        where: u.discord_id == ^to_string(discord_id),\n        left_join: uss in Soundboard.UserSoundSetting,\n        on: uss.user_id == u.id and uss.is_leave_sound == true,\n        left_join: s in Sound,\n        on: s.id == uss.sound_id,\n        select: s.filename,\n        limit: 1\n    )\n  end\n\n  @spec get_user_sound_preferences_by_discord_id(term()) :: map() | nil\n  def get_user_sound_preferences_by_discord_id(discord_id) do\n    case Repo.get_by(User, discord_id: to_string(discord_id)) do\n      nil ->\n        nil\n\n      user ->\n        %{\n          user_id: user.id,\n          join_sound: get_user_join_sound(user.id),\n          leave_sound: get_user_leave_sound(user.id)\n        }\n    end\n  end\n\n  @spec get_sound!(term()) :: Sound.t()\n  def get_sound!(id) do\n    Sound\n    |> Repo.get!(id)\n    |> Repo.preload(@detailed_preloads)\n  end\n\n  @spec update_sound(Sound.t(), map()) :: {:ok, Sound.t()} | {:error, Ecto.Changeset.t()}\n  def update_sound(sound, attrs) do\n    sound\n    |> Sound.changeset(attrs)\n    |> Repo.update()\n  end\n\n  @spec update_sound(Sound.t(), integer(), map()) :: {:ok, Sound.t()} | {:error, term()}\n  def update_sound(sound, user_id, params), do: Management.update_sound(sound, user_id, params)\n\n  @spec delete_sound(Sound.t(), integer()) :: :ok | {:error, term()}\n  def delete_sound(sound, user_id), do: Management.delete_sound(sound, user_id)\n\n  @spec new_create_request(User.t() | nil, map()) :: CreateRequest.t()\n  def new_create_request(user, attrs), do: CreateRequest.new(user, attrs)\n\n  @spec put_request_upload(CreateRequest.t(), map() | nil) :: CreateRequest.t()\n  def put_request_upload(request, upload), do: CreateRequest.put_upload(request, upload)\n\n  @spec validate_create(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}\n  def validate_create(request), do: Uploads.validate(request)\n\n  @spec create_sound(CreateRequest.t()) :: {:ok, Sound.t()} | {:error, Ecto.Changeset.t()}\n  def create_sound(request), do: Uploads.create(request)\n\n  @spec create_error_message(Ecto.Changeset.t() | String.t() | term()) :: String.t()\n  def create_error_message(error), do: Uploads.error_message(error)\nend\n"
  },
  {
    "path": "lib/soundboard/stats/play.ex",
    "content": "defmodule Soundboard.Stats.Play do\n  @moduledoc \"\"\"\n  The Play module.\n  \"\"\"\n\n  use Ecto.Schema\n  import Ecto.Changeset\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Sound\n\n  schema \"plays\" do\n    field :played_filename, :string\n    belongs_to :sound, Sound\n    belongs_to :user, User\n\n    timestamps()\n  end\n\n  def changeset(play, attrs) do\n    play\n    |> cast(attrs, [:played_filename, :sound_id, :user_id])\n    |> validate_required([:played_filename, :sound_id, :user_id])\n    |> assoc_constraint(:sound)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/stats.ex",
    "content": "defmodule Soundboard.Stats do\n  @moduledoc \"\"\"\n  Handles the stats of the soundboard.\n  \"\"\"\n\n  import Ecto.Query\n  import Ecto.Changeset, only: [add_error: 3, change: 1]\n\n  alias Soundboard.{Accounts.User, PubSubTopics, Repo, Sounds, Stats.Play}\n\n  @type leaderboard_entry :: {String.t(), non_neg_integer()}\n  @type recent_play_entry :: {integer(), String.t(), String.t(), NaiveDateTime.t()}\n\n  @spec track_play(String.t(), integer() | nil) :: {:ok, Play.t()} | {:error, Ecto.Changeset.t()}\n  def track_play(sound_name, user_id) do\n    with {:ok, sound_id} <- Sounds.fetch_sound_id(sound_name),\n         {:ok, play} <-\n           insert_play(%{played_filename: sound_name, sound_id: sound_id, user_id: user_id}) do\n      broadcast_stats_update()\n      {:ok, play}\n    else\n      :error -> {:error, add_error(change(%Play{}), :sound_id, \"can't be blank\")}\n      {:error, _changeset} = result -> result\n    end\n  end\n\n  defp get_week_range do\n    today = Date.utc_today()\n    days_since_monday = Date.day_of_week(today, :monday)\n    start_date = Date.add(today, -days_since_monday + 1)\n    end_date = Date.add(start_date, 6)\n\n    {\n      DateTime.new!(start_date, ~T[00:00:00], \"Etc/UTC\"),\n      DateTime.new!(end_date, ~T[23:59:59], \"Etc/UTC\")\n    }\n  end\n\n  @spec get_top_users(Date.t(), Date.t(), keyword()) :: [leaderboard_entry()]\n  def get_top_users(start_date, end_date, opts \\\\ []) do\n    limit = Keyword.get(opts, :limit, 10)\n\n    from(p in Play,\n      join: u in assoc(p, :user),\n      where: fragment(\"DATE(?) BETWEEN ? AND ?\", p.inserted_at, ^start_date, ^end_date),\n      group_by: u.username,\n      select: {u.username, count(p.id)},\n      order_by: [desc: count(p.id)],\n      limit: ^limit\n    )\n    |> Repo.all()\n  end\n\n  @spec get_top_sounds(Date.t(), Date.t(), keyword()) :: [leaderboard_entry()]\n  def get_top_sounds(start_date, end_date, opts \\\\ []) do\n    limit = Keyword.get(opts, :limit, 10)\n\n    from(p in Play,\n      where: fragment(\"DATE(?) BETWEEN ? AND ?\", p.inserted_at, ^start_date, ^end_date),\n      group_by: p.played_filename,\n      select: {p.played_filename, count(p.id)},\n      order_by: [desc: count(p.id)],\n      limit: ^limit\n    )\n    |> Repo.all()\n  end\n\n  @spec get_recent_plays(keyword()) :: [recent_play_entry()]\n  def get_recent_plays(opts \\\\ []) do\n    limit = Keyword.get(opts, :limit, 5)\n\n    from(p in Play,\n      join: u in User,\n      on: p.user_id == u.id,\n      select: {p.id, p.played_filename, u.username, p.inserted_at},\n      order_by: [desc: p.inserted_at, desc: p.id],\n      limit: ^limit\n    )\n    |> Repo.all()\n  end\n\n  @spec reset_weekly_stats() :: :ok | {:error, term()}\n  def reset_weekly_stats do\n    {week_start, _week_end} = get_week_range()\n\n    from(p in Play, where: p.inserted_at < ^week_start)\n    |> Repo.delete_all()\n\n    broadcast_stats_update()\n  end\n\n  @spec broadcast_stats_update() :: :ok | {:error, term()}\n  def broadcast_stats_update do\n    PubSubTopics.broadcast_stats_updated()\n  end\n\n  defp insert_play(attrs) do\n    %Play{}\n    |> Play.changeset(attrs)\n    |> Repo.insert()\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/tag.ex",
    "content": "defmodule Soundboard.Tag do\n  @moduledoc \"\"\"\n  The Tag module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n  import Ecto.Query\n\n  schema \"tags\" do\n    field :name, :string\n\n    many_to_many :sounds, Soundboard.Sound,\n      join_through: Soundboard.SoundTag,\n      on_replace: :delete\n\n    timestamps()\n  end\n\n  def changeset(tag, attrs) do\n    tag\n    |> cast(attrs, [:name])\n    |> validate_required([:name])\n    |> unique_constraint(:name)\n  end\n\n  def search(query \\\\ __MODULE__, search_term) do\n    from t in query,\n      where: like(fragment(\"lower(?)\", t.name), ^\"%#{String.downcase(search_term)}%\"),\n      order_by: t.name,\n      limit: 10\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/uploads_path.ex",
    "content": "defmodule Soundboard.UploadsPath do\n  @moduledoc \"\"\"\n  Central source of truth for uploaded sound storage paths.\n  \"\"\"\n\n  @default_relative_dir \"priv/static/uploads\"\n\n  @type path_input :: String.t() | [String.t()]\n\n  def dir do\n    Application.get_env(:soundboard, :uploads_dir, @default_relative_dir)\n    |> expand_dir()\n  end\n\n  def file_path(filename) when is_binary(filename) do\n    Path.join(dir(), filename)\n  end\n\n  def joined_path(path_segments) when is_list(path_segments) do\n    Path.join([dir() | path_segments])\n  end\n\n  def joined_path(path) when is_binary(path) do\n    Path.join(dir(), path)\n  end\n\n  @spec safe_joined_path(path_input()) :: {:ok, String.t()} | :error\n  def safe_joined_path(path) do\n    base_dir = dir() |> Path.expand()\n\n    candidate =\n      path\n      |> normalize_path_segments()\n      |> then(&Path.join([base_dir | &1]))\n      |> Path.expand()\n\n    if within_uploads_dir?(candidate, base_dir) do\n      {:ok, candidate}\n    else\n      :error\n    end\n  end\n\n  defp normalize_path_segments(path) when is_binary(path), do: [path]\n  defp normalize_path_segments(path_segments) when is_list(path_segments), do: path_segments\n\n  defp within_uploads_dir?(candidate, base_dir) do\n    candidate == base_dir or String.starts_with?(candidate, base_dir <> \"/\")\n  end\n\n  defp expand_dir(path) when is_binary(path) do\n    case Path.type(path) do\n      :absolute -> path\n      _ -> Application.app_dir(:soundboard, path)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard/user_sound_setting.ex",
    "content": "defmodule Soundboard.UserSoundSetting do\n  @moduledoc \"\"\"\n  The UserSoundSetting module.\n  \"\"\"\n  use Ecto.Schema\n  import Ecto.Changeset\n  import Ecto.Query\n\n  alias Soundboard.Repo\n\n  schema \"user_sound_settings\" do\n    belongs_to :user, Soundboard.Accounts.User\n    belongs_to :sound, Soundboard.Sound\n    field :is_join_sound, :boolean, default: false\n    field :is_leave_sound, :boolean, default: false\n\n    timestamps()\n  end\n\n  def changeset(settings, attrs) do\n    settings\n    |> cast(attrs, [:user_id, :sound_id, :is_join_sound, :is_leave_sound])\n    |> validate_required([:user_id, :sound_id])\n  end\n\n  def clear_conflicting_settings(user_id, sound_id, is_join_sound, is_leave_sound) do\n    maybe_clear_join_sound(user_id, sound_id, is_join_sound)\n    maybe_clear_leave_sound(user_id, sound_id, is_leave_sound)\n    :ok\n  end\n\n  defp maybe_clear_join_sound(user_id, sound_id, true) do\n    from(uss in __MODULE__,\n      where:\n        uss.user_id == ^user_id and\n          uss.sound_id != ^sound_id and\n          uss.is_join_sound == true\n    )\n    |> Repo.update_all(set: [is_join_sound: false])\n\n    :ok\n  end\n\n  defp maybe_clear_join_sound(_user_id, _sound_id, _is_join_sound), do: :ok\n\n  defp maybe_clear_leave_sound(user_id, sound_id, true) do\n    from(uss in __MODULE__,\n      where:\n        uss.user_id == ^user_id and\n          uss.sound_id != ^sound_id and\n          uss.is_leave_sound == true\n    )\n    |> Repo.update_all(set: [is_leave_sound: false])\n\n    :ok\n  end\n\n  defp maybe_clear_leave_sound(_user_id, _sound_id, _is_leave_sound), do: :ok\nend\n"
  },
  {
    "path": "lib/soundboard/volume.ex",
    "content": "defmodule Soundboard.Volume do\n  @moduledoc \"\"\"\n  Helpers for working with volume percentages and decimal ratios.\n  \"\"\"\n\n  @type percent :: 0..150\n\n  @spec clamp_percent(number()) :: percent()\n  def clamp_percent(value) do\n    value\n    |> round()\n    |> min(150)\n    |> max(0)\n  end\n\n  @spec normalize_percent(String.t() | number() | nil, percent()) :: percent()\n  def normalize_percent(value, default_percent) do\n    default_percent\n    |> clamp_percent()\n    |> do_normalize(value)\n  end\n\n  @spec percent_to_decimal(String.t() | number() | nil) :: float()\n  def percent_to_decimal(percent), do: percent_to_decimal(percent, 100)\n\n  @spec percent_to_decimal(String.t() | number() | nil, percent()) :: float()\n  def percent_to_decimal(value, default_percent) do\n    value\n    |> normalize_percent(default_percent)\n    |> convert_percent_to_decimal()\n  end\n\n  @spec decimal_to_percent(number() | nil) :: percent()\n  def decimal_to_percent(nil), do: 100\n\n  def decimal_to_percent(decimal) when is_number(decimal) do\n    decimal\n    |> max(0.0)\n    |> min(1.5)\n    |> do_decimal_to_percent()\n  end\n\n  defp do_decimal_to_percent(value) when value <= 1.0 do\n    value\n    |> Kernel.*(100)\n    |> clamp_percent()\n  end\n\n  defp do_decimal_to_percent(value) do\n    value\n    |> Kernel.-(1.0)\n    |> Kernel.*(100)\n    |> Kernel.+(100)\n    |> clamp_percent()\n  end\n\n  defp do_normalize(default, nil), do: default\n  defp do_normalize(_default, value) when is_integer(value), do: clamp_percent(value)\n  defp do_normalize(_default, value) when is_float(value), do: clamp_percent(value)\n\n  defp do_normalize(default, value) when is_binary(value) do\n    value\n    |> String.trim()\n    |> Float.parse()\n    |> case do\n      {parsed, _rest} -> clamp_percent(parsed)\n      :error -> default\n    end\n  end\n\n  defp do_normalize(default, _), do: default\n\n  defp convert_percent_to_decimal(percent) when percent in 0..150 do\n    cond do\n      percent == 0 ->\n        0.0\n\n      percent <= 100 ->\n        percent\n        |> Kernel./(100)\n        |> Float.round(4)\n\n      true ->\n        Float.round(1.0 + (percent - 100) * 0.01, 4)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard.ex",
    "content": "defmodule Soundboard do\n  @moduledoc \"\"\"\n  Soundboard keeps the contexts that define your domain\n  and business logic.\n\n  Contexts are also responsible for managing your data, regardless\n  if it comes from the database, an external API or others.\n  \"\"\"\n\n  @doc \"\"\"\n  Returns the application name.\n  \"\"\"\n  def app_name, do: :soundboard\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/core_components.ex",
    "content": "defmodule SoundboardWeb.CoreComponents do\n  @moduledoc \"\"\"\n  Provides core UI components.\n\n  At first glance, this module may seem daunting, but its goal is to provide\n  core building blocks for your application, such as modals, tables, and\n  forms. The components consist mostly of markup and are well-documented\n  with doc strings and declarative assigns. You may customize and style\n  them in any way you want, based on your application growth and needs.\n\n  The default components use Tailwind CSS, a utility-first CSS framework.\n  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn\n  how to customize them or feel free to swap in another framework altogether.\n\n  Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.\n  \"\"\"\n  use Phoenix.Component\n  use Gettext, backend: SoundboardWeb.Gettext\n\n  alias Phoenix.HTML.Form\n  alias Phoenix.LiveView.JS\n\n  @doc \"\"\"\n  Renders a modal.\n\n  ## Examples\n\n      <.modal id=\"confirm-modal\">\n        This is a modal.\n      </.modal>\n\n  JS commands may be passed to the `:on_cancel` to configure\n  the closing/cancel event, for example:\n\n      <.modal id=\"confirm\" on_cancel={JS.navigate(~p\"/posts\")}>\n        This is another modal.\n      </.modal>\n\n  \"\"\"\n  attr :id, :string, required: true\n  attr :show, :boolean, default: false\n  attr :on_cancel, JS, default: %JS{}\n  slot :inner_block, required: true\n\n  def modal(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      phx-mounted={@show && show_modal(@id)}\n      phx-remove={hide_modal(@id)}\n      data-cancel={JS.exec(@on_cancel, \"phx-remove\")}\n      class=\"relative z-50 hidden\"\n    >\n      <div id={\"#{@id}-bg\"} class=\"bg-zinc-50/90 fixed inset-0 transition-opacity\" aria-hidden=\"true\" />\n      <div\n        class=\"fixed inset-0 overflow-y-auto\"\n        aria-labelledby={\"#{@id}-title\"}\n        aria-describedby={\"#{@id}-description\"}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        tabindex=\"0\"\n      >\n        <div class=\"flex min-h-full items-center justify-center\">\n          <div class=\"w-full max-w-3xl p-4 sm:p-6 lg:py-8\">\n            <.focus_wrap\n              id={\"#{@id}-container\"}\n              phx-window-keydown={JS.exec(\"data-cancel\", to: \"##{@id}\")}\n              phx-key=\"escape\"\n              phx-click-away={JS.exec(\"data-cancel\", to: \"##{@id}\")}\n              class=\"shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition\"\n            >\n              <div class=\"absolute top-6 right-5\">\n                <button\n                  phx-click={JS.exec(\"data-cancel\", to: \"##{@id}\")}\n                  type=\"button\"\n                  class=\"-m-3 flex-none p-3 opacity-20 hover:opacity-40\"\n                  aria-label={gettext(\"close\")}\n                >\n                  <.icon name=\"hero-x-mark-solid\" class=\"h-5 w-5\" />\n                </button>\n              </div>\n              <div id={\"#{@id}-content\"}>\n                {render_slot(@inner_block)}\n              </div>\n            </.focus_wrap>\n          </div>\n        </div>\n      </div>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders flash notices.\n\n  ## Examples\n\n      <.flash kind={:info} flash={@flash} />\n      <.flash kind={:info} phx-mounted={show(\"#flash\")}>Welcome Back!</.flash>\n  \"\"\"\n  attr :id, :string, doc: \"the optional id of flash container\"\n  attr :flash, :map, default: %{}, doc: \"the map of flash messages to display\"\n  attr :title, :string, default: nil\n  attr :kind, :atom, values: [:info, :error], doc: \"used for styling and flash lookup\"\n  attr :rest, :global, doc: \"the arbitrary HTML attributes to add to the flash container\"\n\n  slot :inner_block, doc: \"the optional inner block that renders the flash message\"\n\n  def flash(assigns) do\n    assigns = assign_new(assigns, :id, fn -> \"flash-#{assigns.kind}\" end)\n\n    ~H\"\"\"\n    <div\n      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}\n      id={@id}\n      phx-click={JS.push(\"lv:clear-flash\", value: %{key: @kind}) |> hide(\"##{@id}\")}\n      role=\"alert\"\n      class={[\n        \"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1\",\n        @kind == :info && \"bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900\",\n        @kind == :error && \"bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900\"\n      ]}\n      {@rest}\n    >\n      <p :if={@title} class=\"flex items-center gap-1.5 text-sm font-semibold leading-6\">\n        <.icon :if={@kind == :info} name=\"hero-information-circle-mini\" class=\"h-4 w-4\" />\n        <.icon :if={@kind == :error} name=\"hero-exclamation-circle-mini\" class=\"h-4 w-4\" />\n        {@title}\n      </p>\n      <p class=\"mt-2 text-sm leading-5\">{msg}</p>\n      <button type=\"button\" class=\"group absolute top-1 right-1 p-2\" aria-label={gettext(\"close\")}>\n        <.icon name=\"hero-x-mark-solid\" class=\"h-5 w-5 opacity-40 group-hover:opacity-70\" />\n      </button>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Shows the flash group with standard titles and content.\n\n  ## Examples\n\n      <.flash_group flash={@flash} />\n  \"\"\"\n  attr :flash, :map, required: true, doc: \"the map of flash messages\"\n  attr :id, :string, default: \"flash-group\", doc: \"the optional id of flash container\"\n\n  def flash_group(assigns) do\n    ~H\"\"\"\n    <div id={@id}>\n      <.flash kind={:info} title={gettext(\"Success!\")} flash={@flash} />\n      <.flash kind={:error} title={gettext(\"Error!\")} flash={@flash} />\n      <.flash\n        id=\"client-error\"\n        kind={:error}\n        title={gettext(\"We can't find the internet\")}\n        phx-disconnected={show(\".phx-client-error #client-error\")}\n        phx-connected={hide(\"#client-error\")}\n        hidden\n      >\n        {gettext(\"Attempting to reconnect\")}\n        <.icon name=\"hero-arrow-path\" class=\"ml-1 h-3 w-3 animate-spin\" />\n      </.flash>\n\n      <.flash\n        id=\"server-error\"\n        kind={:error}\n        title={gettext(\"Something went wrong!\")}\n        phx-disconnected={show(\".phx-server-error #server-error\")}\n        phx-connected={hide(\"#server-error\")}\n        hidden\n      >\n        {gettext(\"Hang in there while we get back on track\")}\n        <.icon name=\"hero-arrow-path\" class=\"ml-1 h-3 w-3 animate-spin\" />\n      </.flash>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a simple form.\n\n  ## Examples\n\n      <.simple_form for={@form} phx-change=\"validate\" phx-submit=\"save\">\n        <.input field={@form[:email]} label=\"Email\"/>\n        <.input field={@form[:username]} label=\"Username\" />\n        <:actions>\n          <.button>Save</.button>\n        </:actions>\n      </.simple_form>\n  \"\"\"\n  attr :for, :any, required: true, doc: \"the data structure for the form\"\n  attr :as, :any, default: nil, doc: \"the server side parameter to collect all input under\"\n\n  attr :rest, :global,\n    include: ~w(autocomplete name rel action enctype method novalidate target multipart),\n    doc: \"the arbitrary HTML attributes to apply to the form tag\"\n\n  slot :inner_block, required: true\n  slot :actions, doc: \"the slot for form actions, such as a submit button\"\n\n  def simple_form(assigns) do\n    ~H\"\"\"\n    <.form :let={f} for={@for} as={@as} {@rest}>\n      <div class=\"mt-10 space-y-8 bg-white\">\n        {render_slot(@inner_block, f)}\n        <div :for={action <- @actions} class=\"mt-2 flex items-center justify-between gap-6\">\n          {render_slot(action, f)}\n        </div>\n      </div>\n    </.form>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a button.\n\n  ## Examples\n\n      <.button>Send!</.button>\n      <.button phx-click=\"go\" class=\"ml-2\">Send!</.button>\n  \"\"\"\n  attr :type, :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global, include: ~w(disabled form name value)\n\n  slot :inner_block, required: true\n\n  def button(assigns) do\n    ~H\"\"\"\n    <button\n      type={@type}\n      class={[\n        \"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3\",\n        \"text-sm font-semibold leading-6 text-white active:text-white/80\",\n        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </button>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders an input with label and error messages.\n\n  A `Phoenix.HTML.FormField` may be passed as argument,\n  which is used to retrieve the input name, id, and values.\n  Otherwise all attributes may be passed explicitly.\n\n  ## Types\n\n  This function accepts all HTML input types, considering that:\n\n    * You may also set `type=\"select\"` to render a `<select>` tag\n\n    * `type=\"checkbox\"` is used exclusively to render boolean values\n\n    * For live file uploads, see `Phoenix.Component.live_file_input/1`\n\n  See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input\n  for more information. Unsupported types, such as hidden and radio,\n  are best written directly in your templates.\n\n  ## Examples\n\n      <.input field={@form[:email]} type=\"email\" />\n      <.input name=\"my-input\" errors={[\"oh no!\"]} />\n  \"\"\"\n  attr :id, :any, default: nil\n  attr :name, :any\n  attr :label, :string, default: nil\n  attr :value, :any\n\n  attr :type, :string,\n    default: \"text\",\n    values: ~w(checkbox color date datetime-local email file month number password\n               range search select tel text textarea time url week)\n\n  attr :field, Phoenix.HTML.FormField,\n    doc: \"a form field struct retrieved from the form, for example: @form[:email]\"\n\n  attr :errors, :list, default: []\n  attr :checked, :boolean, doc: \"the checked flag for checkbox inputs\"\n  attr :prompt, :string, default: nil, doc: \"the prompt for select inputs\"\n  attr :options, :list, doc: \"the options to pass to Phoenix.HTML.Form.options_for_select/2\"\n  attr :multiple, :boolean, default: false, doc: \"the multiple flag for select inputs\"\n\n  attr :rest, :global,\n    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength\n                multiple pattern placeholder readonly required rows size step)\n\n  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do\n    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []\n\n    assigns\n    |> assign(field: nil, id: assigns.id || field.id)\n    |> assign(:errors, Enum.map(errors, &translate_error(&1)))\n    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> \"[]\", else: field.name end)\n    |> assign_new(:value, fn -> field.value end)\n    |> input()\n  end\n\n  def input(%{type: \"checkbox\"} = assigns) do\n    assigns =\n      assign_new(assigns, :checked, fn ->\n        Form.normalize_value(\"checkbox\", assigns[:value])\n      end)\n\n    ~H\"\"\"\n    <div>\n      <label class=\"flex items-center gap-4 text-sm leading-6 text-zinc-600\">\n        <input type=\"hidden\" name={@name} value=\"false\" disabled={@rest[:disabled]} />\n        <input\n          type=\"checkbox\"\n          id={@id}\n          name={@name}\n          value=\"true\"\n          checked={@checked}\n          class=\"rounded border-zinc-300 text-zinc-900 focus:ring-0\"\n          {@rest}\n        />\n        {@label}\n      </label>\n      <.error :for={msg <- @errors}>{msg}</.error>\n    </div>\n    \"\"\"\n  end\n\n  def input(%{type: \"select\"} = assigns) do\n    ~H\"\"\"\n    <div>\n      <.label for={@id}>{@label}</.label>\n      <select\n        id={@id}\n        name={@name}\n        class=\"mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm\"\n        multiple={@multiple}\n        {@rest}\n      >\n        <option :if={@prompt} value=\"\">{@prompt}</option>\n        {Phoenix.HTML.Form.options_for_select(@options, @value)}\n      </select>\n      <.error :for={msg <- @errors}>{msg}</.error>\n    </div>\n    \"\"\"\n  end\n\n  def input(%{type: \"textarea\"} = assigns) do\n    ~H\"\"\"\n    <div>\n      <.label for={@id}>{@label}</.label>\n      <textarea\n        id={@id}\n        name={@name}\n        class={[\n          \"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]\",\n          @errors == [] && \"border-zinc-300 focus:border-zinc-400\",\n          @errors != [] && \"border-rose-400 focus:border-rose-400\"\n        ]}\n        {@rest}\n      >{Phoenix.HTML.Form.normalize_value(\"textarea\", @value)}</textarea>\n      <.error :for={msg <- @errors}>{msg}</.error>\n    </div>\n    \"\"\"\n  end\n\n  # All other inputs text, datetime-local, url, password, etc. are handled here...\n  def input(assigns) do\n    ~H\"\"\"\n    <div>\n      <.label for={@id}>{@label}</.label>\n      <input\n        type={@type}\n        name={@name}\n        id={@id}\n        value={Phoenix.HTML.Form.normalize_value(@type, @value)}\n        class={[\n          \"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6\",\n          @errors == [] && \"border-zinc-300 focus:border-zinc-400\",\n          @errors != [] && \"border-rose-400 focus:border-rose-400\"\n        ]}\n        {@rest}\n      />\n      <.error :for={msg <- @errors}>{msg}</.error>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a label.\n  \"\"\"\n  attr :for, :string, default: nil\n  slot :inner_block, required: true\n\n  def label(assigns) do\n    ~H\"\"\"\n    <label for={@for} class=\"block text-sm font-semibold leading-6 text-zinc-800\">\n      {render_slot(@inner_block)}\n    </label>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Generates a generic error message.\n  \"\"\"\n  slot :inner_block, required: true\n\n  def error(assigns) do\n    ~H\"\"\"\n    <p class=\"mt-3 flex gap-3 text-sm leading-6 text-rose-600\">\n      <.icon name=\"hero-exclamation-circle-mini\" class=\"mt-0.5 h-5 w-5 flex-none\" />\n      {render_slot(@inner_block)}\n    </p>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a header with title.\n  \"\"\"\n  attr :class, :string, default: nil\n\n  slot :inner_block, required: true\n  slot :subtitle\n  slot :actions\n\n  def header(assigns) do\n    ~H\"\"\"\n    <header class={[@actions != [] && \"flex items-center justify-between gap-6\", @class]}>\n      <div>\n        <h1 class=\"text-lg font-semibold leading-8 text-zinc-800\">\n          {render_slot(@inner_block)}\n        </h1>\n        <p :if={@subtitle != []} class=\"mt-2 text-sm leading-6 text-zinc-600\">\n          {render_slot(@subtitle)}\n        </p>\n      </div>\n      <div class=\"flex-none\">{render_slot(@actions)}</div>\n    </header>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  Renders a table with generic styling.\n\n  ## Examples\n\n      <.table id=\"users\" rows={@users}>\n        <:col :let={user} label=\"id\">{user.id}</:col>\n        <:col :let={user} label=\"username\">{user.username}</:col>\n      </.table>\n  \"\"\"\n  attr :id, :string, required: true\n  attr :rows, :list, required: true\n  attr :row_id, :any, default: nil, doc: \"the function for generating the row id\"\n  attr :row_click, :any, default: nil, doc: \"the function for handling phx-click on each row\"\n\n  attr :row_item, :any,\n    default: &Function.identity/1,\n    doc: \"the function for mapping each row before calling the :col and :action slots\"\n\n  slot :col, required: true do\n    attr :label, :string\n  end\n\n  slot :action, doc: \"the slot for showing user actions in the last table column\"\n\n  def table(assigns) do\n    assigns =\n      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do\n        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)\n      end\n\n    ~H\"\"\"\n    <div class=\"overflow-y-auto px-4 sm:overflow-visible sm:px-0\">\n      <table class=\"w-[40rem] mt-11 sm:w-full\">\n        <thead class=\"text-sm text-left leading-6 text-zinc-500\">\n          <tr>\n            <th :for={col <- @col} class=\"p-0 pb-4 pr-6 font-normal\">{col[:label]}</th>\n            <th :if={@action != []} class=\"relative p-0 pb-4\">\n              <span class=\"sr-only\">{gettext(\"Actions\")}</span>\n            </th>\n          </tr>\n        </thead>\n        <tbody\n          id={@id}\n          phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && \"stream\"}\n          class=\"relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700\"\n        >\n          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class=\"group hover:bg-zinc-50\">\n            <td\n              :for={{col, i} <- Enum.with_index(@col)}\n              phx-click={@row_click && @row_click.(row)}\n              class={[\"relative p-0\", @row_click && \"hover:cursor-pointer\"]}\n            >\n              <div class=\"block py-4 pr-6\">\n                <span class=\"absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl\" />\n                <span class={[\"relative\", i == 0 && \"font-semibold text-zinc-900\"]}>\n                  {render_slot(col, @row_item.(row))}\n                </span>\n              </div>\n            </td>\n            <td :if={@action != []} class=\"relative w-14 p-0\">\n              <div class=\"relative whitespace-nowrap py-4 text-right text-sm font-medium\">\n                <span class=\"absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl\" />\n                <span\n                  :for={action <- @action}\n                  class=\"relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700\"\n                >\n                  {render_slot(action, @row_item.(row))}\n                </span>\n              </div>\n            </td>\n          </tr>\n        </tbody>\n      </table>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a data list.\n\n  ## Examples\n\n      <.list>\n        <:item title=\"Title\">{@post.title}</:item>\n        <:item title=\"Views\">{@post.views}</:item>\n      </.list>\n  \"\"\"\n  slot :item, required: true do\n    attr :title, :string, required: true\n  end\n\n  def list(assigns) do\n    ~H\"\"\"\n    <div class=\"mt-14\">\n      <dl class=\"-my-4 divide-y divide-zinc-100\">\n        <div :for={item <- @item} class=\"flex gap-4 py-4 text-sm leading-6 sm:gap-8\">\n          <dt class=\"w-1/4 flex-none text-zinc-500\">{item.title}</dt>\n          <dd class=\"text-zinc-700\">{render_slot(item)}</dd>\n        </div>\n      </dl>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a back navigation link.\n\n  ## Examples\n\n      <.back navigate={~p\"/posts\"}>Back to posts</.back>\n  \"\"\"\n  attr :navigate, :any, required: true\n  slot :inner_block, required: true\n\n  def back(assigns) do\n    ~H\"\"\"\n    <div class=\"mt-16\">\n      <.link\n        navigate={@navigate}\n        class=\"text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700\"\n      >\n        <.icon name=\"hero-arrow-left-solid\" class=\"h-3 w-3\" />\n        {render_slot(@inner_block)}\n      </.link>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a [Heroicon](https://heroicons.com).\n\n  Heroicons come in three styles – outline, solid, and mini.\n  By default, the outline style is used, but solid and mini may\n  be applied by using the `-solid` and `-mini` suffix.\n\n  You can customize the size and colors of the icons by setting\n  width, height, and background color classes.\n\n  Icons are extracted from the `deps/heroicons` directory and bundled within\n  your compiled app.css by the plugin in your `assets/tailwind.config.js`.\n\n  ## Examples\n\n      <.icon name=\"hero-x-mark-solid\" />\n      <.icon name=\"hero-arrow-path\" class=\"ml-1 w-3 h-3 animate-spin\" />\n  \"\"\"\n  attr :name, :string, required: true\n  attr :class, :string, default: nil\n\n  def icon(%{name: \"hero-\" <> _} = assigns) do\n    ~H\"\"\"\n    <span class={[@name, @class]} />\n    \"\"\"\n  end\n\n  ## JS Commands\n\n  def show(js \\\\ %JS{}, selector) do\n    JS.show(js,\n      to: selector,\n      time: 300,\n      transition:\n        {\"transition-all transform ease-out duration-300\",\n         \"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\",\n         \"opacity-100 translate-y-0 sm:scale-100\"}\n    )\n  end\n\n  def hide(js \\\\ %JS{}, selector) do\n    JS.hide(js,\n      to: selector,\n      time: 200,\n      transition:\n        {\"transition-all transform ease-in duration-200\",\n         \"opacity-100 translate-y-0 sm:scale-100\",\n         \"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"}\n    )\n  end\n\n  def show_modal(js \\\\ %JS{}, id) when is_binary(id) do\n    js\n    |> JS.show(to: \"##{id}\")\n    |> JS.show(\n      to: \"##{id}-bg\",\n      time: 300,\n      transition: {\"transition-all transform ease-out duration-300\", \"opacity-0\", \"opacity-100\"}\n    )\n    |> show(\"##{id}-container\")\n    |> JS.add_class(\"overflow-hidden\", to: \"body\")\n    |> JS.focus_first(to: \"##{id}-content\")\n  end\n\n  def hide_modal(js \\\\ %JS{}, id) do\n    js\n    |> JS.hide(\n      to: \"##{id}-bg\",\n      transition: {\"transition-all transform ease-in duration-200\", \"opacity-100\", \"opacity-0\"}\n    )\n    |> hide(\"##{id}-container\")\n    |> JS.hide(to: \"##{id}\", transition: {\"block\", \"block\", \"hidden\"})\n    |> JS.remove_class(\"overflow-hidden\", to: \"body\")\n    |> JS.pop_focus()\n  end\n\n  @doc \"\"\"\n  Translates an error message using gettext.\n  \"\"\"\n  def translate_error({msg, opts}) do\n    # When using gettext, we typically pass the strings we want\n    # to translate as a static argument:\n    #\n    #     # Translate the number of files with plural rules\n    #     dngettext(\"errors\", \"1 file\", \"%{count} files\", count)\n    #\n    # However the error messages in our forms and APIs are generated\n    # dynamically, so we need to translate them by calling Gettext\n    # with our gettext backend as first argument. Translations are\n    # available in the errors.po file (as we use the \"errors\" domain).\n    if count = opts[:count] do\n      Gettext.dngettext(SoundboardWeb.Gettext, \"errors\", msg, msg, count, opts)\n    else\n      Gettext.dgettext(SoundboardWeb.Gettext, \"errors\", msg, opts)\n    end\n  end\n\n  @doc \"\"\"\n  Translates the errors for a field from a keyword list of errors.\n  \"\"\"\n  def translate_errors(errors, field) when is_list(errors) do\n    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/flash_component.ex",
    "content": "defmodule SoundboardWeb.Components.FlashComponent do\n  @moduledoc \"\"\"\n  The flash component.\n  \"\"\"\n  use Phoenix.Component\n\n  def flash(assigns) do\n    ~H\"\"\"\n    <div class=\"fixed top-4 right-4 z-50 flex flex-col gap-2\">\n      <%= if message = Phoenix.Flash.get(@flash, :info) do %>\n        <div class=\"rounded-md bg-blue-50 dark:bg-blue-900 p-4\">\n          <p class=\"text-sm font-medium text-blue-800 dark:text-blue-200\">\n            {message}\n          </p>\n        </div>\n      <% end %>\n\n      <%= if message = Phoenix.Flash.get(@flash, :error) do %>\n        <div class=\"rounded-md bg-red-50 dark:bg-red-900 p-4\">\n          <p class=\"text-sm font-medium text-red-800 dark:text-red-200\">\n            {message}\n          </p>\n        </div>\n      <% end %>\n    </div>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/layouts/app.html.heex",
    "content": "<header class=\"h-16\">\n  <.live_component\n    module={SoundboardWeb.Components.Layouts.Navbar}\n    id=\"navbar\"\n    current_user={@current_user}\n    current_path={@current_path}\n    presences={@presences}\n  />\n</header>\n\n<main class=\"\">\n  <div>\n    <.flash_group flash={@flash} />\n    {@inner_content}\n  </div>\n</main>\n"
  },
  {
    "path": "lib/soundboard_web/components/layouts/navbar.ex",
    "content": "defmodule SoundboardWeb.Components.Layouts.Navbar do\n  @moduledoc \"\"\"\n  The navbar component.\n  \"\"\"\n  use Phoenix.LiveComponent\n  use SoundboardWeb, :html\n\n  @impl true\n  def mount(socket) do\n    {:ok, assign(socket, :show_mobile_menu, false)}\n  end\n\n  @impl true\n  def handle_event(\"toggle-mobile-menu\", _, socket) do\n    {:noreply, assign(socket, :show_mobile_menu, !socket.assigns.show_mobile_menu)}\n  end\n\n  @impl true\n  def render(assigns) do\n    ~H\"\"\"\n    <nav class=\"fixed w-full top-0 left-0 right-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50\">\n      <div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n        <div class=\"flex justify-between h-16\">\n          <div class=\"flex\">\n            <div class=\"flex-shrink-0 flex items-center\">\n              <span class=\"text-xl font-bold text-gray-800 dark:text-white\">\n                <.link navigate=\"/\">SoundBored</.link>\n              </span>\n            </div>\n            <div class=\"hidden sm:ml-6 sm:flex sm:space-x-8\">\n              <.nav_link navigate=\"/\" active={current_page?(@current_path, \"/\")}>\n                Sounds\n              </.nav_link>\n              <.nav_link navigate=\"/favorites\" active={current_page?(@current_path, \"/favorites\")}>\n                Favorites\n              </.nav_link>\n              <.nav_link navigate=\"/stats\" active={current_page?(@current_path, \"/stats\")}>\n                Stats\n              </.nav_link>\n              <%= if @current_user do %>\n                <.nav_link\n                  navigate=\"/settings\"\n                  active={current_page?(@current_path, \"/settings\")}\n                >\n                  Settings\n                </.nav_link>\n              <% end %>\n            </div>\n          </div>\n\n          <div class=\"hidden sm:ml-6 sm:flex sm:items-center\">\n            <div class=\"flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400\">\n              <%= visible_users(@presences)\n                  |> Enum.map(fn user -> %>\n                <div class=\"flex items-center gap-1\">\n                  <span\n                    id={\"user-#{user.username}\"}\n                    data-username={user.username}\n                    class={[\n                      \"px-2 py-1 rounded-full text-xs select-none transition-all duration-150 flex items-center gap-1\",\n                      \"cursor-default\",\n                      SoundboardWeb.PresenceHandler.get_user_color(user.username)\n                    ]}\n                  >\n                    <img\n                      src={user.avatar}\n                      class=\"w-4 h-4 rounded-full\"\n                      alt={\"#{user.username}'s avatar\"}\n                    />\n                    {user.username}\n                  </span>\n                </div>\n              <% end) %>\n            </div>\n          </div>\n\n          <div class=\"-mr-2 flex items-center sm:hidden\">\n            <button\n              type=\"button\"\n              class=\"inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500\"\n              aria-controls=\"mobile-menu\"\n              aria-expanded=\"false\"\n              phx-click=\"toggle-mobile-menu\"\n              phx-target={@myself}\n            >\n              <span class=\"sr-only\">Open main menu</span>\n              <!-- Menu open: \"hidden\", Menu closed: \"block\" -->\n              <svg\n                class={[\"h-6 w-6\", (!@show_mobile_menu && \"block\") || \"hidden\"]}\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n                aria-hidden=\"true\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M4 6h16M4 12h16M4 18h16\"\n                />\n              </svg>\n              <!-- Menu open: \"block\", Menu closed: \"hidden\" -->\n              <svg\n                class={[\"h-6 w-6\", (@show_mobile_menu && \"block\") || \"hidden\"]}\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n                aria-hidden=\"true\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M6 18L18 6M6 6l12 12\"\n                />\n              </svg>\n            </button>\n          </div>\n        </div>\n      </div>\n      \n    <!-- Mobile menu -->\n      <div class={[\"sm:hidden\", (!@show_mobile_menu && \"hidden\") || \"block\"]} id=\"mobile-menu\">\n        <div class=\"pt-2 pb-3 space-y-1\">\n          <.mobile_nav_link navigate=\"/\" active={current_page?(@current_path, \"/\")}>\n            Sounds\n          </.mobile_nav_link>\n          <.mobile_nav_link navigate=\"/favorites\" active={current_page?(@current_path, \"/favorites\")}>\n            Favorites\n          </.mobile_nav_link>\n          <.mobile_nav_link navigate=\"/stats\" active={current_page?(@current_path, \"/stats\")}>\n            Stats\n          </.mobile_nav_link>\n          <%= if @current_user do %>\n            <.mobile_nav_link\n              navigate=\"/settings\"\n              active={current_page?(@current_path, \"/settings\")}\n            >\n              Settings\n            </.mobile_nav_link>\n          <% end %>\n        </div>\n        <div class=\"pt-4 pb-3 border-t border-gray-200 dark:border-gray-700\">\n          <div class=\"space-y-2 px-4\">\n            <%= visible_users(@presences)\n                |> Enum.map(fn user -> %>\n              <div class=\"flex items-center gap-2 py-2\">\n                <span\n                  id={\"mobile-user-#{user.username}\"}\n                  data-username={user.username}\n                  class={[\n                    \"px-3 py-2 rounded-full text-sm select-none transition-all duration-150 flex items-center gap-2\",\n                    \"cursor-default leading-relaxed tracking-wide\",\n                    SoundboardWeb.PresenceHandler.get_user_color(user.username)\n                  ]}\n                >\n                  <img\n                    src={user.avatar}\n                    class=\"w-5 h-5 rounded-full\"\n                    alt={\"#{user.username}'s avatar\"}\n                  />\n                  <span class=\"truncate\">{user.username}</span>\n                </span>\n              </div>\n            <% end) %>\n          </div>\n        </div>\n      </div>\n    </nav>\n    \"\"\"\n  end\n\n  defp nav_link(assigns) do\n    ~H\"\"\"\n    <.link\n      navigate={@navigate}\n      class={[\n        \"inline-flex items-center px-1 pt-1 text-sm font-medium\",\n        if(@active,\n          do: \"border-b-2 border-blue-500 text-gray-900 dark:text-gray-100\",\n          else:\n            \"border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-200\"\n        )\n      ]}\n    >\n      {render_slot(@inner_block)}\n    </.link>\n    \"\"\"\n  end\n\n  defp mobile_nav_link(assigns) do\n    ~H\"\"\"\n    <.link\n      navigate={@navigate}\n      class={[\n        \"block pl-4 pr-4 py-3 border-l-4 text-base font-medium leading-relaxed tracking-wide\",\n        if(@active,\n          do: \"bg-blue-50 dark:bg-blue-900/50 border-blue-500 text-blue-700 dark:text-blue-100\",\n          else:\n            \"border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-800 dark:hover:text-gray-200\"\n        )\n      ]}\n    >\n      {render_slot(@inner_block)}\n    </.link>\n    \"\"\"\n  end\n\n  defp visible_users(presences) do\n    presences\n    |> Enum.flat_map(fn {_id, presence} ->\n      Enum.map(presence.metas, & &1.user)\n    end)\n    |> Enum.uniq_by(& &1.username)\n  end\n\n  defp current_page?(current_path, path), do: current_path == path\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/layouts/root.html.heex",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"[scrollbar-gutter:stable]\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\" />\n    <meta name=\"theme-color\" content=\"#1f2937\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no\"\n    />\n    <meta name=\"csrf-token\" content={get_csrf_token()} />\n    <meta name=\"apple-mobile-web-app-title\" content=\"SoundBored\" />\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-touch-fullscreen\" content=\"yes\" />\n\n    <link rel=\"apple-touch-icon\" href={~p\"/images/icon-192.png\"} />\n    <.live_title suffix=\"\">\n      {assigns[:page_title] || \"Soundboard\"}\n    </.live_title>\n    <link phx-track-static rel=\"stylesheet\" href={~p\"/assets/app.css\"} />\n    <script defer phx-track-static type=\"text/javascript\" src={~p\"/assets/app.js\"}>\n    </script>\n  </head>\n  <body class=\"antialiased bg-gray-900\">\n    {@inner_content}\n  </body>\n</html>\n"
  },
  {
    "path": "lib/soundboard_web/components/layouts.ex",
    "content": "defmodule SoundboardWeb.Layouts do\n  @moduledoc false\n  use SoundboardWeb, :html\n\n  embed_templates \"layouts/*\"\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/delete_modal.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.DeleteModal do\n  @moduledoc \"\"\"\n  The delete modal component.\n  \"\"\"\n  use Phoenix.Component\n\n  def delete_modal(assigns) do\n    ~H\"\"\"\n    <%= if @show_delete_confirm do %>\n      <div class=\"fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-50\">\n        <div class=\"fixed inset-0 z-50 overflow-y-auto\">\n          <div class=\"flex min-h-full items-center justify-center p-4 text-center sm:p-0\">\n            <div class=\"relative transform rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg\">\n              <div class=\"p-6\">\n                <h3 class=\"text-lg font-medium text-gray-900 dark:text-gray-100 mb-4\">\n                  Delete Sound\n                </h3>\n                <p class=\"text-gray-600 dark:text-gray-400 mb-4\">\n                  Are you sure you want to delete this sound? This action cannot be undone.\n                </p>\n                <div class=\"flex justify-end gap-3\">\n                  <button\n                    phx-click=\"hide_delete_confirm\"\n                    class=\"inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50\"\n                  >\n                    Cancel\n                  </button>\n                  <button\n                    phx-click=\"delete_sound\"\n                    class=\"inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700\"\n                  >\n                    Delete\n                  </button>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    <% end %>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/edit_modal.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.EditModal do\n  @moduledoc \"\"\"\n  The edit modal component.\n  \"\"\"\n  use Phoenix.Component\n  alias Soundboard.Volume\n  alias SoundboardWeb.Components.Soundboard.{TagComponents, VolumeControl}\n\n  attr :flash, :map, default: %{}\n  attr :edit_name_error, :string, default: nil\n  attr :current_user, :map, required: true\n  attr :current_sound, :map, required: true\n  attr :tag_input, :string, default: \"\"\n  attr :tag_suggestions, :list, default: []\n\n  def edit_modal(assigns) do\n    assigns = assign_new(assigns, :edit_name_error, fn -> nil end)\n\n    assigns =\n      update(assigns, :current_sound, fn sound ->\n        tags =\n          case sound.tags do\n            tags when is_list(tags) -> tags\n            _ -> []\n          end\n\n        Map.put(sound, :tags, tags)\n      end)\n\n    ~H\"\"\"\n    <div\n      class=\"fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-10\"\n      phx-window-keydown=\"close_modal_key\"\n      phx-key=\"Escape\"\n    >\n      <div class=\"fixed inset-0 z-10 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n          <div class=\"relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\">\n            <div class=\"absolute right-0 top-0 pr-4 pt-4\">\n              <button\n                phx-click=\"close_modal\"\n                type=\"button\"\n                class=\"rounded-md bg-white dark:bg-gray-800 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300\"\n              >\n                <span class=\"sr-only\">Close</span>\n                <svg\n                  class=\"h-6 w-6\"\n                  fill=\"none\"\n                  viewBox=\"0 0 24 24\"\n                  stroke-width=\"1.5\"\n                  stroke=\"currentColor\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n                </svg>\n              </button>\n            </div>\n\n            <div class=\"mt-3 text-center sm:mt-5\">\n              <h3 class=\"text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4\">\n                Edit Sound\n              </h3>\n\n              <form phx-submit=\"save_sound\" phx-change=\"validate_sound\" id=\"edit-form\" class=\"mt-4\">\n                <input type=\"hidden\" name=\"sound_id\" value={@current_sound.id} />\n                <input type=\"hidden\" name=\"source_type\" value={@current_sound.source_type} />\n                <input type=\"hidden\" name=\"url\" value={@current_sound.url} />\n                \n    <!-- Display current source type (non-editable) -->\n                <div class=\"mb-4 text-left\">\n                  <label class=\"block text-sm font-medium text-gray-500 dark:text-gray-400\">\n                    Source\n                  </label>\n                  <div class=\"mt-1 text-sm text-gray-700 dark:text-gray-300\">\n                    <%= if @current_sound.source_type == \"url\" do %>\n                      URL: {@current_sound.url}\n                    <% else %>\n                      Local File\n                    <% end %>\n                  </div>\n                </div>\n                \n    <!-- Name Input with error message -->\n                <div class=\"mb-4\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 text-left\">\n                    Name\n                  </label>\n                  <input\n                    type=\"text\"\n                    name=\"filename\"\n                    value={\n                      String.replace(\n                        @current_sound.filename,\n                        Path.extname(@current_sound.filename),\n                        \"\"\n                      )\n                    }\n                    required\n                    placeholder=\"Sound name\"\n                    phx-debounce=\"400\"\n                    class={\"mt-1 block w-full rounded-md shadow-sm sm:text-sm\n                           dark:text-gray-100 #{if @edit_name_error, do: \"border-red-300 focus:border-red-500 focus:ring-red-500\", else: \"border-gray-300 dark:border-gray-600 focus:border-blue-500 focus:ring-blue-500\"}\n                           dark:bg-gray-700\"}\n                  />\n                  <%= if @edit_name_error do %>\n                    <p class=\"mt-2 text-sm text-red-600 dark:text-red-400\">{@edit_name_error}</p>\n                  <% end %>\n                </div>\n\n                <% volume_percent = Volume.decimal_to_percent(@current_sound.volume) %>\n                <% preview_kind = if @current_sound.source_type == \"url\", do: \"url\", else: \"existing\" %>\n                <% preview_src =\n                  if preview_kind == \"existing\",\n                    do: \"/uploads/#{@current_sound.filename}\",\n                    else: @current_sound.url %>\n                <VolumeControl.volume_control\n                  id=\"edit-volume-control\"\n                  value={volume_percent}\n                  target=\"edit\"\n                  data-preview-kind={preview_kind}\n                  data-preview-src={preview_src}\n                  preview_disabled={is_nil(preview_src) or preview_src == \"\"}\n                />\n                \n    <!-- Tags -->\n                <div class=\"text-left\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\n                    Tags\n                  </label>\n                  <TagComponents.tag_badge_list tags={@current_sound.tags} remove_event=\"remove_tag\" />\n                </div>\n                \n    <!-- Tag Input -->\n                <div class=\"mt-2 relative\">\n                  <div>\n                    <TagComponents.tag_input_field\n                      value={@tag_input}\n                      placeholder=\"Type a tag and press Enter...\"\n                      input_id=\"tag-input\"\n                      phx-keyup=\"tag_input\"\n                      phx-keydown=\"add_tag\"\n                      phx-value-value={@tag_input}\n                      onkeydown=\"\n                        if(event.key === 'Enter') {\n                          event.preventDefault();\n                          const value = this.value;\n                          requestAnimationFrame(() => this.value = '');\n                          return false;\n                        }\n                      \"\n                      autocomplete=\"off\"\n                    />\n                  </div>\n\n                  <TagComponents.tag_suggestions_dropdown\n                    tag_input={@tag_input}\n                    tag_suggestions={@tag_suggestions}\n                    select_event=\"select_tag\"\n                  />\n                </div>\n                \n    <!-- Sound Settings -->\n                <div class=\"mt-5 mb-4\">\n                  <div class=\"flex flex-col gap-3 text-left\">\n                    <% user_setting =\n                      Enum.find(\n                        @current_sound.user_sound_settings || [],\n                        &(&1.user_id == @current_user.id)\n                      ) %>\n                    <label class=\"relative flex items-start\">\n                      <div class=\"flex h-6 items-center\">\n                        <input\n                          type=\"checkbox\"\n                          name=\"is_join_sound\"\n                          value=\"true\"\n                          checked={user_setting && user_setting.is_join_sound}\n                          class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                        />\n                      </div>\n                      <div class=\"ml-3 text-sm leading-6\">\n                        <span class=\"font-medium text-gray-900 dark:text-gray-100\">\n                          Play when I join voice\n                        </span>\n                      </div>\n                    </label>\n\n                    <label class=\"relative flex items-start\">\n                      <div class=\"flex h-6 items-center\">\n                        <input\n                          type=\"checkbox\"\n                          name=\"is_leave_sound\"\n                          value=\"true\"\n                          checked={user_setting && user_setting.is_leave_sound}\n                          class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600\"\n                        />\n                      </div>\n                      <div class=\"ml-3 text-sm leading-6\">\n                        <span class=\"font-medium text-gray-900 dark:text-gray-100\">\n                          Play when I leave voice\n                        </span>\n                      </div>\n                    </label>\n                  </div>\n                </div>\n\n                <div class=\"mt-5 sm:mt-6 flex gap-3\">\n                  <button\n                    type=\"submit\"\n                    disabled={@edit_name_error}\n                    class={\"flex-1 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm\n                            #{if @edit_name_error,\n                              do: \"bg-gray-400 cursor-not-allowed\",\n                              else: \"bg-blue-600 hover:bg-blue-500\"}\"}\n                  >\n                    Save Changes\n                  </button>\n                  <%= if @current_sound.user_id == @current_user.id do %>\n                    <button\n                      type=\"button\"\n                      phx-click=\"show_delete_confirm\"\n                      class=\"flex-1 rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500\"\n                    >\n                      Delete Sound\n                    </button>\n                  <% end %>\n                </div>\n              </form>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/helpers.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.Helpers do\n  @moduledoc \"\"\"\n  Helper functions for the soundboard.\n  \"\"\"\n  def format_bytes(bytes) when is_integer(bytes) do\n    cond do\n      bytes >= 1_000_000 -> \"#{Float.round(bytes / 1_000_000, 1)} MB\"\n      bytes >= 1_000 -> \"#{Float.round(bytes / 1_000, 1)} KB\"\n      true -> \"#{bytes} B\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/tag_components.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.TagComponents do\n  @moduledoc \"\"\"\n  Shared tag UI helpers for the soundboard modals.\n  \"\"\"\n  use Phoenix.Component\n  alias SoundboardWeb.Live.Support.LiveTags\n\n  attr :tags, :list, default: []\n  attr :remove_event, :string, required: true\n  attr :tag_key, :atom, default: :name\n  attr :wrapper_class, :string, default: \"mt-2 flex flex-wrap gap-2\"\n\n  def tag_badge_list(assigns) do\n    assigns = assign_new(assigns, :tag_key, fn -> :name end)\n\n    ~H\"\"\"\n    <div class={@wrapper_class}>\n      <%= for tag <- @tags do %>\n        <% tag_name = tag_value(tag, @tag_key) %>\n        <span class=\"inline-flex items-center gap-1 rounded-full bg-blue-50 dark:bg-blue-900 px-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-300\">\n          {tag_name}\n          <button\n            type=\"button\"\n            phx-click={@remove_event}\n            phx-value-tag={tag_name}\n            class=\"text-blue-600 dark:text-blue-300 hover:text-blue-500 dark:hover:text-blue-200\"\n          >\n            <svg class=\"h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n              <path d=\"M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z\" />\n            </svg>\n          </button>\n        </span>\n      <% end %>\n    </div>\n    \"\"\"\n  end\n\n  attr :tag_input, :string, default: \"\"\n  attr :tag_suggestions, :list, default: []\n  attr :select_event, :string, required: true\n  attr :tag_key, :atom, default: :name\n\n  attr :wrapper_class, :string,\n    default:\n      \"absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm\"\n\n  attr :suggestion_class, :string,\n    default:\n      \"w-full text-left px-4 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900 dark:text-gray-100\"\n\n  def tag_suggestions_dropdown(assigns) do\n    assigns = assign_new(assigns, :tag_input, fn -> \"\" end)\n\n    ~H\"\"\"\n    <%= if String.trim(@tag_input || \"\") != \"\" and @tag_suggestions != [] do %>\n      <div class={@wrapper_class}>\n        <%= for tag <- @tag_suggestions do %>\n          <% tag_name = tag_value(tag, @tag_key) %>\n          <button\n            type=\"button\"\n            phx-click={@select_event}\n            phx-value-tag={tag_name}\n            class={@suggestion_class}\n          >\n            {tag_name}\n          </button>\n        <% end %>\n      </div>\n    <% end %>\n    \"\"\"\n  end\n\n  attr :tag, :any, required: true\n  attr :selected_tags, :list, required: true\n  attr :uploaded_files, :list, required: true\n  attr :tag_key, :atom, default: :name\n  attr :click_event, :string, default: \"toggle_tag_filter\"\n  attr :class, :any, default: []\n\n  def tag_filter_button(assigns) do\n    assigns = assign_new(assigns, :tag_key, fn -> :name end)\n\n    ~H\"\"\"\n    <button\n      phx-click={@click_event}\n      phx-value-tag={tag_value(@tag, @tag_key)}\n      class={[\n        \"inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium\",\n        if(LiveTags.tag_selected?(@tag, @selected_tags),\n          do: \"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300\",\n          else:\n            \"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700\"\n        )\n        | List.wrap(@class)\n      ]}\n    >\n      {tag_value(@tag, @tag_key)}\n      <span class=\"text-xs\">({LiveTags.count_sounds_with_tag(@uploaded_files, @tag)})</span>\n    </button>\n    \"\"\"\n  end\n\n  attr :value, :string, default: \"\"\n  attr :placeholder, :string, default: \"Type a tag and press Enter...\"\n  attr :input_id, :string, default: nil\n  attr :disabled, :boolean, default: false\n  attr :class, :string, default: \"\"\n  attr :onkeydown, :string, default: nil\n  attr :autocomplete, :string, default: nil\n  attr :rest, :global\n\n  def tag_input_field(assigns) do\n    assigns = assign_new(assigns, :value, fn -> \"\" end)\n\n    base_class =\n      \"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 \" <>\n        \"focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400\"\n\n    assigns = assign(assigns, :base_class, base_class)\n\n    ~H\"\"\"\n    <input\n      type=\"text\"\n      value={@value}\n      placeholder={@placeholder}\n      id={@input_id}\n      disabled={@disabled}\n      class={[@base_class, @class]}\n      onkeydown={@onkeydown}\n      autocomplete={@autocomplete}\n      {@rest}\n    />\n    \"\"\"\n  end\n\n  defp tag_value(tag, tag_key) when is_atom(tag_key) do\n    case tag do\n      %{^tag_key => value} -> value\n      %{} -> Map.get(tag, :name) || tag\n      _ -> tag\n    end\n  end\n\n  defp tag_value(tag, _tag_key), do: tag\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/upload_modal.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.UploadModal do\n  @moduledoc \"\"\"\n  The upload modal component.\n  \"\"\"\n  use Phoenix.Component\n  alias SoundboardWeb.Components.Soundboard.{TagComponents, VolumeControl}\n\n  def upload_modal(assigns) do\n    ~H\"\"\"\n    <div class=\"fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-10\">\n      <div class=\"fixed inset-0 z-10 overflow-y-auto\">\n        <div class=\"flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0\">\n          <div class=\"relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6\">\n            <div class=\"absolute right-0 top-0 pr-4 pt-4\">\n              <button\n                phx-click=\"close_upload_modal\"\n                type=\"button\"\n                class=\"rounded-md bg-white dark:bg-gray-800 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300\"\n              >\n                <span class=\"sr-only\">Close</span>\n                <svg\n                  class=\"h-6 w-6\"\n                  fill=\"none\"\n                  viewBox=\"0 0 24 24\"\n                  stroke-width=\"1.5\"\n                  stroke=\"currentColor\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n                </svg>\n              </button>\n            </div>\n\n            <div class=\"mt-3 text-center sm:mt-5\">\n              <h3 class=\"text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4\">\n                Add Sound\n              </h3>\n\n              <form\n                phx-submit=\"save_upload\"\n                phx-change=\"validate_upload\"\n                id=\"upload-form\"\n                class=\"mt-4\"\n              >\n                <% source_ready = source_input_ready?(@source_type, @uploads.audio.entries, @url) %>\n                <% form_ready = form_ready?(@source_type, @uploads.audio.entries, @url, @upload_error) %>\n                <% local_upload_pending = local_upload_pending?(@source_type, @uploads.audio.entries) %>\n                <% url_upload_pending = url_upload_pending?(@source_type, @url) %>\n                \n    <!-- Source Type -->\n                <div class=\"mb-4\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 text-left\">\n                    Source Type\n                  </label>\n                  <select\n                    name=\"source_type\"\n                    phx-change=\"change_source_type\"\n                    class=\"mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm\n                           focus:border-blue-500 focus:ring-blue-500 sm:text-sm\n                           dark:bg-gray-700 dark:text-gray-100\"\n                  >\n                    <option value=\"local\" selected={@source_type == \"local\"}>Local File</option>\n                    <option value=\"url\" selected={@source_type == \"url\"}>URL</option>\n                  </select>\n                </div>\n\n                <%= if @source_type == \"local\" do %>\n                  <!-- Local File Input -->\n                  <div class=\"mb-4\">\n                    <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 text-left mb-2\">\n                      File\n                    </label>\n                    <div class=\"flex items-center gap-2\">\n                      <.live_file_input\n                        upload={@uploads.audio}\n                        id=\"upload-audio-input\"\n                        class=\"block w-full text-sm text-gray-500 dark:text-gray-400\n                               file:mr-4 file:py-2 file:px-4\n                               file:rounded-md file:border-0\n                               file:text-sm file:font-semibold\n                               file:bg-blue-50 file:text-blue-700\n                               dark:file:bg-blue-900 dark:file:text-blue-300\n                               hover:file:bg-blue-100 dark:hover:file:bg-blue-800\"\n                      />\n                    </div>\n                  </div>\n                <% else %>\n                  <!-- URL Input -->\n                  <div class=\"mb-4\">\n                    <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 text-left\">\n                      URL\n                    </label>\n                    <input\n                      type=\"url\"\n                      name=\"url\"\n                      value={@url}\n                      required\n                      id=\"upload-url-input\"\n                      placeholder=\"https://example.com/sound.mp3\"\n                      phx-change=\"validate_upload\"\n                      phx-debounce=\"400\"\n                      class=\"mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm\n                             focus:border-blue-500 focus:ring-blue-500 sm:text-sm\n                             dark:bg-gray-700 dark:text-gray-100\"\n                    />\n                  </div>\n                <% end %>\n                \n    <!-- Details: shown but inputs are disabled until a file/URL is provided -->\n                  <!-- Name Input -->\n                <div class=\"mb-4\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300 text-left\">\n                    Name\n                  </label>\n                  <input\n                    type=\"text\"\n                    name=\"name\"\n                    value={@upload_name}\n                    required\n                    placeholder=\"Sound name\"\n                    phx-change=\"validate_upload\"\n                    phx-debounce=\"400\"\n                    disabled={!source_ready}\n                    class=\"mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm\n                             focus:border-blue-500 focus:ring-blue-500 sm:text-sm\n                             dark:bg-gray-700 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed\"\n                  />\n                  <%= if local_upload_pending do %>\n                    <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n                      Select a file first to name it.\n                    </p>\n                  <% end %>\n                  <%= if url_upload_pending do %>\n                    <p class=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n                      Enter a URL first to name it.\n                    </p>\n                  <% end %>\n                  <%= if @upload_error do %>\n                    <p class=\"mt-1 text-sm text-red-600 dark:text-red-400\">{@upload_error}</p>\n                  <% end %>\n                </div>\n                \n    <!-- Tags -->\n                <div class=\"text-left\">\n                  <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\n                    Tags\n                  </label>\n                  <TagComponents.tag_badge_list tags={@upload_tags} remove_event=\"remove_upload_tag\" />\n\n                  <div class=\"mt-2 relative\">\n                    <div>\n                      <TagComponents.tag_input_field\n                        value={@upload_tag_input}\n                        placeholder=\"Type a tag and press Enter or Tab...\"\n                        input_id=\"upload-tag-input\"\n                        disabled={!source_ready}\n                        phx-keyup=\"upload_tag_input\"\n                        phx-keydown=\"add_upload_tag\"\n                        phx-value-value={@upload_tag_input}\n                        onkeydown=\"\n                          if(event.key === 'Enter' || event.key === 'Tab') {\n                            event.preventDefault();\n                          }\n                        \"\n                        class=\"disabled:opacity-50 disabled:cursor-not-allowed\"\n                        autocomplete=\"off\"\n                      />\n                    </div>\n\n                    <TagComponents.tag_suggestions_dropdown\n                      tag_input={@upload_tag_input}\n                      tag_suggestions={@upload_tag_suggestions}\n                      select_event=\"select_upload_tag\"\n                    />\n                  </div>\n                </div>\n\n                <% preview_kind = if @source_type == \"local\", do: \"local-upload\", else: \"url\" %>\n                <% preview_disabled = not source_ready %>\n                <VolumeControl.volume_control\n                  id=\"upload-volume-control\"\n                  value={@upload_volume}\n                  target=\"upload\"\n                  data-preview-kind={preview_kind}\n                  data-file-input-id={\n                    if preview_kind == \"local-upload\", do: \"upload-audio-input\", else: nil\n                  }\n                  data-url-input-id={if preview_kind == \"url\", do: \"upload-url-input\", else: nil}\n                  data-preview-src={if preview_kind == \"url\", do: @url, else: nil}\n                  preview_disabled={preview_disabled}\n                />\n                \n    <!-- Sound Settings -->\n                <div class=\"mt-5 mb-4\">\n                  <div class=\"flex flex-col gap-3 text-left\">\n                    <label class=\"relative flex items-start\">\n                      <div class=\"flex h-6 items-center\">\n                        <input\n                          type=\"checkbox\"\n                          name=\"is_join_sound\"\n                          value=\"true\"\n                          checked={@is_join_sound}\n                          phx-click=\"toggle_join_sound\"\n                          disabled={!source_ready}\n                          class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 dark:border-gray-600 dark:focus:ring-offset-gray-800\"\n                        />\n                      </div>\n                      <div class=\"ml-3 text-sm leading-6\">\n                        <span class=\"font-medium text-gray-900 dark:text-gray-100\">\n                          Play when I join voice\n                        </span>\n                      </div>\n                    </label>\n\n                    <label class=\"relative flex items-start\">\n                      <div class=\"flex h-6 items-center\">\n                        <input\n                          type=\"checkbox\"\n                          name=\"is_leave_sound\"\n                          value=\"true\"\n                          checked={@is_leave_sound}\n                          phx-click=\"toggle_leave_sound\"\n                          disabled={!source_ready}\n                          class=\"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 dark:border-gray-600 dark:focus:ring-offset-gray-800\"\n                        />\n                      </div>\n                      <div class=\"ml-3 text-sm leading-6\">\n                        <span class=\"font-medium text-gray-900 dark:text-gray-100\">\n                          Play when I leave voice\n                        </span>\n                      </div>\n                    </label>\n                  </div>\n                </div>\n\n                <div class=\"mt-5 sm:mt-6\">\n                  <button\n                    type=\"submit\"\n                    phx-disable-with=\"Adding...\"\n                    disabled={!form_ready}\n                    class=\"inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2\n                             text-sm font-semibold text-white shadow-sm hover:bg-blue-500\n                             focus-visible:outline focus-visible:outline-2\n                             focus-visible:outline-offset-2 focus-visible:outline-blue-600\n                             dark:focus-visible:outline-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed\"\n                  >\n                    Add Sound\n                  </button>\n                </div>\n              </form>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    \"\"\"\n  end\n\n  defp source_input_ready?(\"local\", entries, _url), do: entries != []\n  defp source_input_ready?(\"url\", _entries, url), do: String.trim(url || \"\") != \"\"\n  defp source_input_ready?(_, _entries, _url), do: false\n\n  defp form_ready?(source_type, entries, url, upload_error) do\n    source_input_ready?(source_type, entries, url) and is_nil(upload_error)\n  end\n\n  defp local_upload_pending?(source_type, entries), do: source_type == \"local\" and entries == []\n\n  defp url_upload_pending?(source_type, url),\n    do: source_type == \"url\" and String.trim(url || \"\") == \"\"\nend\n"
  },
  {
    "path": "lib/soundboard_web/components/soundboard/volume_control.ex",
    "content": "defmodule SoundboardWeb.Components.Soundboard.VolumeControl do\n  @moduledoc \"\"\"\n  Shared volume slider with preview support for upload/edit modals.\n  \"\"\"\n  use Phoenix.Component\n\n  attr :id, :string, default: nil\n  attr :value, :integer, required: true\n  attr :target, :string, required: true\n  attr :push_event, :string, default: \"update_volume\"\n  attr :label, :string, default: \"Volume\"\n  attr :input_name, :string, default: \"volume\"\n  attr :preview_disabled, :boolean, default: false\n  attr :preview_label, :string, default: \"Preview\"\n  attr :max_percent, :integer, default: 150\n  attr :rest, :global\n\n  def volume_control(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      class=\"mb-4 text-left space-y-2\"\n      phx-hook=\"VolumeControl\"\n      data-push-event={@push_event}\n      data-volume-target={@target}\n      data-max-percent={@max_percent}\n      {@rest}\n    >\n      <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">\n        {@label}\n      </label>\n      <input\n        type=\"hidden\"\n        name={@input_name}\n        value={@value}\n        data-role=\"volume-hidden\"\n      />\n      <div class=\"relative\" data-role=\"volume-track\">\n        <input\n          type=\"range\"\n          min=\"0\"\n          max={@max_percent}\n          step=\"1\"\n          value={@value}\n          data-role=\"volume-slider\"\n          data-thumb-size=\"18\"\n          class=\"volume-slider w-full accent-blue-600\"\n        />\n      </div>\n      <div class=\"flex justify-between text-xs text-gray-500 dark:text-gray-400\">\n        <span>0%</span>\n        <span>{@max_percent}%</span>\n      </div>\n      <div class=\"flex items-center justify-between text-sm text-gray-600 dark:text-gray-400\">\n        <span data-role=\"volume-display\">{@value}%</span>\n        <button\n          type=\"button\"\n          data-role=\"volume-preview\"\n          class=\"px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\n          disabled={@preview_disabled}\n        >\n          {@preview_label}\n        </button>\n      </div>\n    </div>\n    \"\"\"\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/controllers/api/sound_controller.ex",
    "content": "defmodule SoundboardWeb.API.SoundController do\n  use SoundboardWeb, :controller\n\n  alias Soundboard.{Repo, Sound, Sounds}\n\n  def index(conn, _params) do\n    sounds =\n      Sound\n      |> Sound.with_tags()\n      |> Repo.all()\n      |> Enum.map(&format_sound(&1, conn.assigns[:current_user]))\n\n    json(conn, %{data: sounds})\n  end\n\n  def create(conn, params) do\n    with {:ok, user} <- require_upload_user(conn),\n         {:ok, sound} <- create_sound(user, params) do\n      conn\n      |> put_status(:created)\n      |> json(%{data: format_sound(sound, user)})\n    else\n      {:error, :forbidden_auth_state} ->\n        conn\n        |> put_status(:forbidden)\n        |> json(%{error: \"Uploads require a user API token\"})\n\n      {:error, %Ecto.Changeset{} = changeset} ->\n        conn\n        |> put_status(:unprocessable_entity)\n        |> json(%{errors: changeset_errors(changeset)})\n    end\n  end\n\n  def play(conn, %{\"id\" => id}) do\n    case Repo.get(Sound, id) do\n      nil ->\n        conn\n        |> put_status(:not_found)\n        |> json(%{error: \"Sound not found\"})\n\n      sound ->\n        case require_play_user(conn) do\n          {:ok, user} ->\n            Soundboard.AudioPlayer.play_sound(sound.filename, user)\n\n            conn\n            |> put_status(:accepted)\n            |> json(%{\n              data: %{\n                status: \"accepted\",\n                message: \"Playback request accepted for #{sound.filename}\",\n                requested_by: user.username,\n                sound: %{id: sound.id, filename: sound.filename}\n              }\n            })\n\n          {:error, :forbidden_auth_state} ->\n            conn\n            |> put_status(:forbidden)\n            |> json(%{error: \"Playback requires a user API token\"})\n        end\n    end\n  end\n\n  def stop(conn, _params) do\n    Soundboard.AudioPlayer.stop_sound()\n\n    conn\n    |> put_status(:accepted)\n    |> json(%{\n      data: %{\n        status: \"accepted\",\n        message: \"Stop request accepted\"\n      }\n    })\n  end\n\n  defp create_sound(user, params) do\n    user\n    |> Sounds.new_create_request(params)\n    |> Sounds.create_sound()\n  end\n\n  defp require_upload_user(conn) do\n    case conn.assigns[:current_user] do\n      %Soundboard.Accounts.User{} = user -> {:ok, user}\n      _ -> {:error, :forbidden_auth_state}\n    end\n  end\n\n  defp require_play_user(conn), do: require_upload_user(conn)\n\n  defp format_sound(sound, current_user) do\n    user_setting = find_user_setting(sound, current_user)\n\n    %{\n      id: sound.id,\n      filename: sound.filename,\n      source_type: sound.source_type,\n      url: sound.url,\n      volume: sound.volume,\n      description: sound.description,\n      tags: Enum.map(sound.tags || [], & &1.name),\n      is_join_sound: user_setting && user_setting.is_join_sound,\n      is_leave_sound: user_setting && user_setting.is_leave_sound,\n      inserted_at: sound.inserted_at,\n      updated_at: sound.updated_at\n    }\n  end\n\n  defp find_user_setting(_sound, nil), do: nil\n\n  defp find_user_setting(sound, user) do\n    settings =\n      if Ecto.assoc_loaded?(sound.user_sound_settings) do\n        sound.user_sound_settings\n      else\n        sound\n        |> Repo.preload(:user_sound_settings)\n        |> Map.get(:user_sound_settings)\n      end\n\n    Enum.find(settings, &(&1.user_id == user.id))\n  end\n\n  defp changeset_errors(changeset) do\n    Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->\n      Regex.replace(~r\"%{(\\w+)}\", message, fn _, key ->\n        opts\n        |> Keyword.get(String.to_existing_atom(key), key)\n        |> to_string()\n      end)\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/controllers/auth_controller.ex",
    "content": "defmodule SoundboardWeb.AuthController do\n  use SoundboardWeb, :controller\n\n  plug Ueberauth\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Discord.RoleChecker\n  alias Soundboard.Repo\n\n  def request(conn, %{\"provider\" => \"discord\"} = _params) do\n    conn\n    |> put_session(:session_id, System.unique_integer())\n    |> configure_session(renew: true)\n  end\n\n  def request(conn, _params) do\n    conn\n    |> put_status(:not_found)\n    |> text(\"Unsupported auth provider\")\n  end\n\n  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do\n    if RoleChecker.authorized?(auth.uid) do\n      user_params = %{\n        discord_id: auth.uid,\n        username: auth.info.nickname || auth.info.name,\n        avatar: auth.info.image\n      }\n\n      case find_or_create_user(user_params) do\n        {:ok, user} ->\n          conn\n          |> put_session(:user_id, user.id)\n          |> put_session(:roles_verified_at, System.system_time(:second))\n          |> redirect(to: \"/\")\n\n        {:error, _reason} ->\n          conn\n          |> put_flash(:error, \"Error signing in\")\n          |> redirect(to: \"/\")\n      end\n    else\n      conn\n      |> put_flash(:error, \"Error signing in\")\n      |> redirect(to: \"/\")\n    end\n  end\n\n  def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do\n    conn\n    |> put_flash(:error, \"Failed to authenticate\")\n    |> redirect(to: \"/\")\n  end\n\n  defp find_or_create_user(%{discord_id: discord_id} = params) do\n    case Repo.get_by(User, discord_id: discord_id) do\n      nil ->\n        %User{}\n        |> User.changeset(params)\n        |> Repo.insert()\n\n      user ->\n        {:ok, user}\n    end\n  end\n\n  def logout(conn, _params) do\n    conn\n    |> clear_session()\n    |> redirect(to: \"/\")\n  end\n\n  def debug_session(conn, _params) do\n    json(conn, %{\n      session: %{\n        session_id: get_session(conn, :session_id),\n        user_id: get_session(conn, :user_id)\n      }\n    })\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/controllers/error_html.ex",
    "content": "defmodule SoundboardWeb.ErrorHTML do\n  @moduledoc \"\"\"\n  Renders fallback HTML error messages.\n  \"\"\"\n  use SoundboardWeb, :html\n\n  def render(template, _assigns) do\n    Phoenix.Controller.status_message_from_template(template)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/controllers/error_json.ex",
    "content": "defmodule SoundboardWeb.ErrorJSON do\n  @moduledoc \"\"\"\n  Renders fallback JSON error payloads.\n  \"\"\"\n\n  def render(template, _assigns) do\n    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/controllers/upload_controller.ex",
    "content": "defmodule SoundboardWeb.UploadController do\n  use SoundboardWeb, :controller\n\n  alias Soundboard.UploadsPath\n\n  def show(conn, %{\"path\" => path}) do\n    case UploadsPath.safe_joined_path(path) do\n      {:ok, file_path} ->\n        if File.regular?(file_path) do\n          send_file(conn, 200, file_path)\n        else\n          send_resp(conn, 404, \"File not found\")\n        end\n\n      :error ->\n        send_resp(conn, 404, \"File not found\")\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/endpoint.ex",
    "content": "defmodule SoundboardWeb.Endpoint do\n  use Phoenix.Endpoint, otp_app: :soundboard\n\n  # The session will be stored in the cookie and signed,\n  # this means its contents can be read but not tampered with.\n  # Set :encryption_salt if you would also like to encrypt it.\n  @session_options [\n    store: :cookie,\n    key: \"_soundboard_key\",\n    signing_salt: \"dxNUerVp\",\n    same_site: \"Lax\"\n  ]\n\n  socket \"/live\", Phoenix.LiveView.Socket,\n    websocket: [connect_info: [session: @session_options]],\n    longpoll: [connect_info: [session: @session_options]]\n\n  # Serve at \"/\" the static files from \"priv/static\" directory.\n  #\n  # You should set gzip to true if you are running phx.digest\n  # when deploying your static files in production.\n  plug Plug.Static,\n    at: \"/\",\n    from: :soundboard,\n    gzip: false,\n    only: SoundboardWeb.static_paths()\n\n  # Code reloading can be explicitly enabled under the\n  # :code_reloader configuration of your endpoint.\n  if code_reloading? do\n    socket \"/phoenix/live_reload/socket\", Phoenix.LiveReloader.Socket\n    plug Phoenix.LiveReloader\n    plug Phoenix.CodeReloader\n    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :soundboard\n  end\n\n  plug Phoenix.LiveDashboard.RequestLogger,\n    param_key: \"request_logger\",\n    cookie_key: \"request_logger\"\n\n  plug Plug.RequestId\n  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]\n\n  plug Plug.Parsers,\n    parsers: [:urlencoded, :multipart, :json],\n    pass: [\"*/*\"],\n    length: 30_000_000,\n    json_decoder: Phoenix.json_library()\n\n  plug Plug.MethodOverride\n  plug Plug.Head\n  plug Plug.Session, @session_options\n  plug SoundboardWeb.Router\nend\n"
  },
  {
    "path": "lib/soundboard_web/gettext.ex",
    "content": "defmodule SoundboardWeb.Gettext do\n  @moduledoc \"\"\"\n  A module providing Internationalization with a gettext-based API.\n\n  By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations\n  that you can use in your application. To use this Gettext backend module,\n  call `use Gettext` and pass it as an option:\n\n      use Gettext, backend: SoundboardWeb.Gettext\n\n      # Simple translation\n      gettext(\"Here is the string to translate\")\n\n      # Plural translation\n      ngettext(\"Here is the string to translate\",\n               \"Here are the strings to translate\",\n               3)\n\n      # Domain-based translation\n      dgettext(\"errors\", \"Here is the error message to translate\")\n\n  See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.\n  \"\"\"\n  use Gettext.Backend, otp_app: :soundboard\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/favorites_live.ex",
    "content": "defmodule SoundboardWeb.FavoritesLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  alias Soundboard.{Favorites, PubSubTopics}\n  alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}\n  import FlashHelpers, only: [flash_sound_played: 2, clear_flash_after_timeout: 1]\n  require Logger\n\n  @impl true\n  def mount(_params, session, socket) do\n    if connected?(socket) do\n      PubSubTopics.subscribe_files()\n      PubSubTopics.subscribe_playback()\n    end\n\n    socket =\n      socket\n      |> mount_presence(session)\n      |> assign(:current_path, \"/favorites\")\n      |> assign(:current_user, get_user_from_session(session))\n      |> assign(:max_favorites, Favorites.max_favorites())\n\n    {:ok, assign_favorites_state(socket, socket.assigns[:current_user])}\n  end\n\n  @impl true\n  def handle_event(\"play\", %{\"name\" => filename}, socket) do\n    SoundPlayback.play(socket, filename)\n  end\n\n  @impl true\n  def handle_event(\"toggle_favorite\", %{\"sound-id\" => sound_id}, socket) do\n    case socket.assigns.current_user do\n      nil ->\n        {:noreply, put_flash(socket, :error, \"You must be logged in to favorite sounds\")}\n\n      user ->\n        case Favorites.toggle_favorite(user.id, sound_id) do\n          {:ok, _favorite} ->\n            {:noreply,\n             socket\n             |> assign_favorites_state(user)\n             |> put_flash(:info, \"Favorites updated!\")}\n\n          {:error, reason} ->\n            {:noreply, put_flash(socket, :error, Favorites.error_message(reason))}\n        end\n    end\n  end\n\n  @impl true\n  def handle_info({:sound_played, %{filename: _, played_by: _} = event}, socket) do\n    {:noreply, flash_sound_played(socket, event)}\n  end\n\n  @impl true\n  def handle_info({:sound_played, filename}, socket) when is_binary(filename) do\n    username =\n      case SoundPlayback.current_username(socket) do\n        {:ok, current_username} -> current_username\n        :error -> \"Someone\"\n      end\n\n    {:noreply,\n     socket\n     |> put_flash(:info, \"#{username} played #{filename}\")\n     |> clear_flash_after_timeout()}\n  end\n\n  @impl true\n  def handle_info({:error, message}, socket) do\n    {:noreply,\n     socket\n     |> put_flash(:error, message)\n     |> clear_flash_after_timeout()}\n  end\n\n  @impl true\n  def handle_info({:files_updated}, socket) do\n    {:noreply, assign_favorites_state(socket, socket.assigns[:current_user])}\n  end\n\n  @impl true\n  def handle_info(:clear_flash, socket) do\n    {:noreply, clear_flash(socket)}\n  end\n\n  @impl true\n  def handle_info({:stats_updated}, socket) do\n    {:noreply, assign_favorites_state(socket, socket.assigns[:current_user])}\n  end\n\n  defp assign_favorites_state(socket, nil) do\n    assign(socket, favorites: [], sounds_with_tags: [])\n  end\n\n  defp assign_favorites_state(socket, user) do\n    favorites = Favorites.list_favorites(user.id)\n\n    assign(socket,\n      favorites: favorites,\n      sounds_with_tags: Favorites.list_favorite_sounds_with_tags(user.id)\n    )\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/favorites_live.html.heex",
    "content": "<div class=\"max-w-6xl mx-auto px-4 py-8\">\n  <div class=\"flex justify-between items-center mb-8\">\n    <h1 class=\"text-3xl font-bold text-gray-800 dark:text-gray-100\">Favorites</h1>\n    <div class=\"text-sm text-gray-600 dark:text-gray-400\">\n      {length(@favorites)}/{@max_favorites} favorites\n    </div>\n  </div>\n\n  <%= if @current_user do %>\n    <%= if @sounds_with_tags == [] do %>\n      <div class=\"flex flex-col items-center justify-center py-16\">\n        <div class=\"text-6xl mb-4\">😢</div>\n        <h3 class=\"text-xl font-medium text-gray-900 dark:text-gray-100 mb-2\">\n          You currently have no favorites\n        </h3>\n        <p class=\"text-gray-500 dark:text-gray-400\">\n          Click the heart icon on any sound to add it to your favorites\n        </p>\n      </div>\n    <% else %>\n      <div class=\"grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-4\">\n        <%= for sound <- @sounds_with_tags do %>\n          <div class=\"relative bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all duration-200\">\n            <button\n              phx-click=\"play\"\n              phx-value-name={sound.filename}\n              class=\"absolute inset-0 w-full h-full cursor-pointer z-0\n                     hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg\n                     focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2\n                     dark:focus:ring-offset-gray-800 pointer-events-auto\"\n            >\n            </button>\n\n            <div class=\"relative z-10 p-4 flex flex-col min-h-[120px] pointer-events-none\">\n              <div class=\"text-gray-800 dark:text-gray-200 font-medium break-all\">\n                {display_name(sound.filename)}\n              </div>\n\n              <div class=\"mt-auto pt-2 flex justify-between items-center\">\n                <div class=\"flex flex-wrap gap-1 items-center\">\n                  <%= if sound.tags != [] do %>\n                    <%= for tag <- sound.tags do %>\n                      <span class=\"inline-flex items-center rounded-full bg-blue-50 dark:bg-blue-900 px-2 py-1 text-xs font-medium text-blue-600 dark:text-blue-300\">\n                        {tag.name}\n                      </span>\n                    <% end %>\n                  <% end %>\n                </div>\n\n                <div class=\"flex items-center gap-2 ml-2 flex-shrink-0 pointer-events-auto\">\n                  <button\n                    id={\"local-play-#{Path.basename(sound.filename, Path.extname(sound.filename))}\"}\n                    phx-hook=\"LocalPlayer\"\n                    data-filename={sound.filename}\n                    data-source-type={sound.source_type}\n                    data-url={sound.url}\n                    data-volume={sound.volume || 1.0}\n                    class=\"relative flex items-center justify-center w-8 h-8 text-gray-400 hover:text-green-600 \n                           dark:text-gray-500 dark:hover:text-green-400 rounded-md transition-colors\"\n                  >\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"currentColor\"\n                      class=\"w-4 h-4 play-icon\"\n                    >\n                      <path d=\"M12 15a3 3 0 100-6 3 3 0 000 6z\" />\n                      <path\n                        fill-rule=\"evenodd\"\n                        d=\"M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 010-1.113zM17.25 12a5.25 5.25 0 11-10.5 0 5.25 5.25 0 0110.5 0z\"\n                        clip-rule=\"evenodd\"\n                      />\n                    </svg>\n                    <svg\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 24 24\"\n                      fill=\"currentColor\"\n                      class=\"w-4 h-4 stop-icon hidden\"\n                    >\n                      <path\n                        fill-rule=\"evenodd\"\n                        d=\"M4.5 7.5a3 3 0 013-3h9a3 3 0 013 3v9a3 3 0 01-3 3h-9a3 3 0 01-3-3v-9z\"\n                        clip-rule=\"evenodd\"\n                      />\n                    </svg>\n                  </button>\n                  <button\n                    phx-click=\"toggle_favorite\"\n                    phx-value-sound-id={sound.id}\n                    class=\"relative flex items-center justify-center w-8 h-8 text-gray-400 hover:text-red-500 \n                           dark:text-gray-500 dark:hover:text-red-500 rounded-md transition-colors\"\n                  >\n                    <%= if sound.id in @favorites do %>\n                      <.icon name=\"hero-heart-solid\" class=\"w-4 h-4 text-red-500\" />\n                    <% else %>\n                      <.icon name=\"hero-heart\" class=\"w-4 h-4\" />\n                    <% end %>\n                  </button>\n                </div>\n              </div>\n            </div>\n          </div>\n        <% end %>\n      </div>\n    <% end %>\n  <% else %>\n    <div class=\"text-center py-12\">\n      <p class=\"text-gray-600 dark:text-gray-400\">\n        Please log in to manage your favorites\n      </p>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "lib/soundboard_web/live/settings_live.ex",
    "content": "defmodule SoundboardWeb.SettingsLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  alias Soundboard.Accounts.ApiTokens\n  alias Soundboard.PublicURL\n\n  @impl true\n  def mount(_params, session, socket) do\n    socket =\n      socket\n      |> mount_presence(session)\n      |> assign(:current_path, \"/settings\")\n      |> assign(:current_user, get_user_from_session(session))\n      |> assign(:tokens, [])\n      |> assign(:new_token, nil)\n      |> assign(:base_url, PublicURL.current())\n\n    {:ok, load_tokens(socket)}\n  end\n\n  @impl true\n  def handle_params(_params, uri, socket) do\n    {:noreply, assign(socket, :base_url, PublicURL.from_uri_or_current(uri))}\n  end\n\n  @impl true\n  def handle_event(\n        \"create_token\",\n        %{\"label\" => label},\n        %{assigns: %{current_user: user}} = socket\n      ) do\n    case ApiTokens.generate_token(user, %{label: String.trim(label)}) do\n      {:ok, raw, _token} ->\n        {:noreply,\n         socket\n         |> assign(:new_token, raw)\n         |> load_tokens()}\n\n      {:error, _changeset} ->\n        {:noreply, put_flash(socket, :error, \"Failed to create token\")}\n    end\n  end\n\n  @impl true\n  def handle_event(\"revoke_token\", %{\"id\" => id}, %{assigns: %{current_user: user}} = socket) do\n    case ApiTokens.revoke_token(user, id) do\n      {:ok, _} -> {:noreply, socket |> load_tokens() |> put_flash(:info, \"Token revoked\")}\n      {:error, :forbidden} -> {:noreply, put_flash(socket, :error, \"Not allowed\")}\n      {:error, :not_found} -> {:noreply, put_flash(socket, :error, \"Token not found\")}\n      {:error, _} -> {:noreply, put_flash(socket, :error, \"Failed to revoke token\")}\n    end\n  end\n\n  defp load_tokens(%{assigns: %{current_user: nil}} = socket), do: socket\n\n  defp load_tokens(%{assigns: %{current_user: user}} = socket) do\n    tokens = ApiTokens.list_tokens(user)\n\n    example =\n      socket.assigns[:new_token] ||\n        case tokens do\n          [%{token: tok} | _] when is_binary(tok) -> tok\n          _ -> nil\n        end\n\n    socket\n    |> assign(:tokens, tokens)\n    |> assign(:example_token, example)\n  end\n\n  @impl true\n  def render(assigns) do\n    ~H\"\"\"\n    <div class=\"max-w-6xl mx-auto px-4 py-6 space-y-6\">\n      <h1 class=\"text-2xl font-bold text-gray-800 dark:text-gray-100\">Settings</h1>\n\n      <section aria-labelledby=\"api-tokens-heading\" class=\"space-y-6\">\n        <header class=\"space-y-2\">\n          <h2 id=\"api-tokens-heading\" class=\"text-xl font-semibold text-gray-800 dark:text-gray-100\">\n            API Tokens\n          </h2>\n          <p class=\"text-sm text-gray-600 dark:text-gray-400\">\n            Create a personal token to play sounds remotely. Requests authenticated with a token\n            are attributed to your account and update your stats.\n          </p>\n        </header>\n\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow p-5 space-y-4\">\n          <form phx-submit=\"create_token\" class=\"flex flex-col gap-3 sm:flex-row sm:items-end\">\n            <div class=\"flex-1\">\n              <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">Label</label>\n              <input\n                name=\"label\"\n                type=\"text\"\n                placeholder=\"e.g., CI Bot\"\n                class=\"mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-blue-500\"\n              />\n            </div>\n            <button\n              type=\"submit\"\n              class=\"w-full sm:w-auto justify-center px-4 py-2 bg-blue-600 text-white rounded-md font-medium hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 flex items-center\"\n            >\n              Create\n            </button>\n          </form>\n        </div>\n\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden\">\n          <div class=\"overflow-x-auto\">\n            <table class=\"min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm\">\n              <thead class=\"bg-gray-50 dark:bg-gray-900\">\n                <tr>\n                  <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">\n                    Label\n                  </th>\n                  <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">\n                    Token\n                  </th>\n                  <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">\n                    Created\n                  </th>\n                  <th class=\"px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">\n                    Last Used\n                  </th>\n                  <th class=\"px-4 py-2\"></th>\n                </tr>\n              </thead>\n              <tbody class=\"divide-y divide-gray-200 dark:divide-gray-700\">\n                <%= for token <- @tokens do %>\n                  <tr class=\"text-sm\">\n                    <td class=\"px-4 py-2 text-gray-900 dark:text-gray-100 whitespace-nowrap\">\n                      {token.label || \"(no label)\"}\n                    </td>\n                    <td class=\"px-4 py-2 align-top\">\n                      <div class=\"relative\">\n                        <button\n                          id={\"copy-token-#{token.id}\"}\n                          type=\"button\"\n                          phx-hook=\"CopyButton\"\n                          data-copy-text={token.token}\n                          class=\"absolute right-2 top-1/2 -translate-y-1/2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                        >\n                          Copy\n                        </button>\n                        <pre class=\"p-2 pr-20 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto whitespace-nowrap\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">{token.token}</code></pre>\n                      </div>\n                    </td>\n                    <td class=\"px-4 py-2 text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                      {format_dt(token.inserted_at)}\n                    </td>\n                    <td class=\"px-4 py-2 text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                      {format_dt(token.last_used_at) || \"—\"}\n                    </td>\n                    <td class=\"px-4 py-2 text-right align-top\">\n                      <button\n                        phx-click=\"revoke_token\"\n                        phx-value-id={token.id}\n                        class=\"px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900\"\n                      >\n                        Revoke\n                      </button>\n                    </td>\n                  </tr>\n                <% end %>\n              </tbody>\n            </table>\n          </div>\n        </div>\n\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow p-5 space-y-4\">\n          <h3 class=\"text-lg font-semibold text-gray-800 dark:text-gray-100\">How to call the API</h3>\n          <p class=\"text-sm text-gray-700 dark:text-gray-300\">\n            Include your token in the Authorization header:\n            <code class=\"px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100 font-mono\">\n              Authorization: Bearer {@example_token || \"<token>\"}\n            </code>\n          </p>\n          <div class=\"space-y-4\">\n            <div>\n              <div class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">List sounds</div>\n              <div class=\"relative\">\n                <button\n                  id=\"copy-list-sounds\"\n                  type=\"button\"\n                  phx-hook=\"CopyButton\"\n                  data-copy-text={\"curl -H \\\"Authorization: Bearer #{(@example_token || \"<TOKEN>\")}\\\" #{@base_url}/api/sounds\"}\n                  class=\"absolute right-2 top-2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                >\n                  Copy\n                </button>\n                <pre class=\"mt-1 p-2 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto whitespace-nowrap min-h-[56px]\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">curl -H \\\"Authorization: Bearer {(@example_token || \"<TOKEN>\")}\\\" {@base_url}/api/sounds</code></pre>\n              </div>\n            </div>\n            <div class=\"text-xs text-gray-600 dark:text-gray-400\">\n              Upload endpoint: <code class=\"font-mono\">POST /api/sounds</code>. Required fields:\n              <code class=\"font-mono\">name</code>\n              plus either <code class=\"font-mono\">file</code>\n              (local multipart)\n              or <code class=\"font-mono\">url</code>\n              (<code class=\"font-mono\">source_type=url</code>). Optional: <code class=\"font-mono\">tags</code>,\n              <code class=\"font-mono\">volume</code>\n              (0-150), <code class=\"font-mono\">is_join_sound</code>, <code class=\"font-mono\">is_leave_sound</code>.\n            </div>\n            <div>\n              <div class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n                Upload local file (multipart/form-data)\n              </div>\n              <div class=\"relative\">\n                <button\n                  id=\"copy-upload-local\"\n                  type=\"button\"\n                  phx-hook=\"CopyButton\"\n                  data-copy-text={\"curl -X POST -H \\\"Authorization: Bearer #{(@example_token || \"<TOKEN>\")}\\\" -F \\\"source_type=local\\\" -F \\\"name=<NAME>\\\" -F \\\"file=@/path/to/sound.mp3\\\" -F \\\"tags[]=meme\\\" -F \\\"tags[]=alert\\\" -F \\\"volume=90\\\" -F \\\"is_join_sound=true\\\" #{@base_url}/api/sounds\"}\n                  class=\"absolute right-2 top-2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                >\n                  Copy\n                </button>\n                <pre class=\"mt-1 p-2 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto min-h-[120px]\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">curl -X POST \\\n    -H \"Authorization: Bearer {(@example_token || \"<TOKEN>\")}\" \\\n    -F \"source_type=local\" \\\n    -F \"name=&lt;NAME&gt;\" \\\n    -F \"file=@/path/to/sound.mp3\" \\\n    -F \"tags[]=meme\" \\\n    -F \"tags[]=alert\" \\\n    -F \"volume=90\" \\\n    -F \"is_join_sound=true\" \\\n    {@base_url}/api/sounds</code></pre>\n              </div>\n            </div>\n            <div>\n              <div class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n                Upload from URL (JSON)\n              </div>\n              <div class=\"relative\">\n                <button\n                  id=\"copy-upload-url\"\n                  type=\"button\"\n                  phx-hook=\"CopyButton\"\n                  data-copy-text={\"curl -X POST -H \\\"Authorization: Bearer #{(@example_token || \"<TOKEN>\")}\\\" -H \\\"Content-Type: application/json\\\" -d '{\\\"source_type\\\":\\\"url\\\",\\\"name\\\":\\\"wow\\\",\\\"url\\\":\\\"https://example.com/wow.mp3\\\",\\\"tags\\\":[\\\"meme\\\",\\\"reaction\\\"],\\\"volume\\\":90,\\\"is_leave_sound\\\":true}' #{@base_url}/api/sounds\"}\n                  class=\"absolute right-2 top-2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                >\n                  Copy\n                </button>\n                <pre class=\"mt-1 p-2 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto min-h-[110px]\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">curl -X POST \\\n    -H \"Authorization: Bearer {(@example_token || \"<TOKEN>\")}\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '&#123;\"source_type\":\"url\",\"name\":\"wow\",\"url\":\"https://example.com/wow.mp3\",\"tags\":[\"meme\",\"reaction\"],\"volume\":90,\"is_leave_sound\":true&#125;' \\\n    {@base_url}/api/sounds</code></pre>\n              </div>\n            </div>\n            <div>\n              <div class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">\n                Play a sound by ID\n              </div>\n              <div class=\"relative\">\n                <button\n                  id=\"copy-play-sound\"\n                  type=\"button\"\n                  phx-hook=\"CopyButton\"\n                  data-copy-text={\"curl -X POST -H \\\"Authorization: Bearer #{(@example_token || \"<TOKEN>\")}\\\" #{@base_url}/api/sounds/<SOUND_ID>/play\"}\n                  class=\"absolute right-2 top-2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                >\n                  Copy\n                </button>\n                <pre class=\"mt-1 p-2 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto whitespace-nowrap min-h-[56px]\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">curl -X POST -H \\\"Authorization: Bearer {(@example_token || \"<TOKEN>\")}\\\" {@base_url}/api/sounds/&lt;SOUND_ID&gt;/play</code></pre>\n              </div>\n            </div>\n            <div>\n              <div class=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Stop all sounds</div>\n              <div class=\"relative\">\n                <button\n                  id=\"copy-stop-sounds\"\n                  type=\"button\"\n                  phx-hook=\"CopyButton\"\n                  data-copy-text={\"curl -X POST -H \\\"Authorization: Bearer #{(@example_token || \"<TOKEN>\")}\\\" #{@base_url}/api/sounds/stop\"}\n                  class=\"absolute right-2 top-2 text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded\"\n                >\n                  Copy\n                </button>\n                <pre class=\"mt-1 p-2 pr-16 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-xs overflow-x-auto whitespace-nowrap min-h-[56px]\"><code class=\"text-gray-800 dark:text-gray-100 font-mono\">curl -X POST -H \\\"Authorization: Bearer {(@example_token || \"<TOKEN>\")}\\\" {@base_url}/api/sounds/stop</code></pre>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n    </div>\n    \"\"\"\n  end\n\n  defp format_dt(nil), do: nil\n  defp format_dt(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, \"%Y-%m-%d %H:%M\")\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live/edit_flow.ex",
    "content": "defmodule SoundboardWeb.Live.SoundboardLive.EditFlow do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 3]\n\n  alias Soundboard.{Sound, Sounds, Volume}\n  alias SoundboardWeb.Live.Support.{LiveTags, TagForm}\n\n  @tag_form %{input_key: :tag_input, suggestions_key: :tag_suggestions}\n\n  defmodule State do\n    @moduledoc false\n\n    defstruct show_modal: false,\n              current_sound: nil,\n              tag_input: \"\",\n              tag_suggestions: [],\n              show_delete_confirm: false,\n              edit_name_error: nil,\n              current_user_id: nil\n\n    @type t :: %__MODULE__{\n            show_modal: boolean(),\n            current_sound: Sound.t() | nil,\n            tag_input: String.t(),\n            tag_suggestions: list(),\n            show_delete_confirm: boolean(),\n            edit_name_error: String.t() | nil,\n            current_user_id: integer() | nil\n          }\n  end\n\n  def assign_defaults(socket), do: put_state(socket, default_state())\n\n  def validate_sound(socket, %{\"_target\" => [\"filename\"]} = params) do\n    current_sound_id = params[\"sound_id\"]\n\n    error =\n      case Sounds.fetch_filename_extension(current_sound_id) do\n        {:ok, extension} ->\n          filename = String.trim(params[\"filename\"] || \"\") <> extension\n\n          if Sounds.filename_taken_excluding?(filename, current_sound_id) do\n            \"A sound with that name already exists\"\n          end\n\n        :error ->\n          nil\n      end\n\n    {:noreply, update_state(socket, &%{&1 | edit_name_error: error})}\n  end\n\n  def validate_sound(socket, _params), do: {:noreply, socket}\n\n  def open_modal(socket, id) do\n    sound = Sounds.get_sound!(id)\n\n    {:noreply,\n     socket\n     |> update_state(fn state ->\n       %{state | current_sound: sound, show_modal: true, edit_name_error: nil}\n     end)}\n  end\n\n  def close_modal(socket), do: put_state(socket, default_state())\n\n  def add_tag(socket, key, value) do\n    edit = state(socket)\n\n    TagForm.handle_key(socket, key, value, current_tags(edit), &append_sound_tag/3, @tag_form)\n  end\n\n  def remove_tag(socket, tag_name) do\n    edit = state(socket)\n    tags = Enum.reject(current_tags(edit), &(&1.name == tag_name))\n\n    {:ok, updated_sound} = LiveTags.update_sound_tags(edit.current_sound, tags)\n    LiveTags.broadcast_update()\n\n    {:noreply,\n     socket\n     |> update_state(&%{&1 | current_sound: updated_sound})\n     |> assign(:uploaded_files, Sounds.list_detailed())}\n  end\n\n  def select_tag_suggestion(socket, tag_name), do: select_tag(socket, tag_name)\n\n  def update_tag_input(socket, value), do: TagForm.update_input(socket, value, @tag_form)\n\n  def select_tag(socket, tag_name) do\n    edit = state(socket)\n\n    TagForm.select_tag(socket, tag_name, current_tags(edit), &append_sound_tag/3, @tag_form)\n  end\n\n  def save_sound(socket, params) do\n    edit = state(socket)\n\n    case Sounds.update_sound(edit.current_sound, edit.current_user_id, params) do\n      {:ok, _updated_sound} ->\n        LiveTags.broadcast_update()\n\n        {:noreply,\n         socket\n         |> Phoenix.LiveView.put_flash(:info, \"Sound updated successfully\")\n         |> close_modal()\n         |> assign(:uploaded_files, Sounds.list_detailed())}\n\n      {:error, error} ->\n        {:noreply,\n         Phoenix.LiveView.put_flash(\n           socket,\n           :error,\n           \"Error updating sound: #{error_message(error)}\"\n         )}\n    end\n  end\n\n  def show_delete_confirm(socket) do\n    {:noreply, update_state(socket, &%{&1 | show_delete_confirm: true})}\n  end\n\n  def hide_delete_confirm(socket) do\n    {:noreply, update_state(socket, &%{&1 | show_delete_confirm: false})}\n  end\n\n  def delete_sound(socket) do\n    edit = state(socket)\n\n    case Sounds.delete_sound(edit.current_sound, edit.current_user_id) do\n      :ok ->\n        {:noreply,\n         socket\n         |> close_modal()\n         |> assign(:uploaded_files, Sounds.list_detailed())\n         |> Phoenix.LiveView.put_flash(:info, \"Sound deleted successfully\")}\n\n      {:error, :forbidden} ->\n        {:noreply,\n         socket\n         |> update_state(&%{&1 | show_delete_confirm: false})\n         |> Phoenix.LiveView.put_flash(:error, \"You can only delete your own sounds\")}\n\n      {:error, _changeset} ->\n        {:noreply,\n         socket\n         |> update_state(&%{&1 | show_delete_confirm: false})\n         |> Phoenix.LiveView.put_flash(:error, \"Failed to delete sound\")}\n    end\n  end\n\n  def update_volume(socket, volume) do\n    edit = state(socket)\n\n    case edit.current_sound do\n      nil ->\n        {:noreply, socket}\n\n      sound ->\n        default_percent = Volume.decimal_to_percent(sound.volume)\n\n        updated_sound =\n          Map.put(sound, :volume, Volume.percent_to_decimal(volume, default_percent))\n\n        {:noreply, update_state(socket, &%{&1 | current_sound: updated_sound})}\n    end\n  end\n\n  defp error_message(%Ecto.Changeset{} = changeset) do\n    Enum.map_join(changeset.errors, \", \", fn {field, {msg, _opts}} ->\n      \"#{field} #{msg}\"\n    end)\n  end\n\n  defp error_message(_), do: \"Failed to update sound\"\n\n  defp append_sound_tag(socket, tag, current_tags) do\n    edit = state(socket)\n\n    case LiveTags.update_sound_tags(edit.current_sound, [tag | current_tags]) do\n      {:ok, updated_sound} ->\n        LiveTags.broadcast_update()\n        {:ok, update_state(socket, &%{&1 | current_sound: updated_sound})}\n\n      {:error, _} ->\n        {:error, \"Failed to add tag\"}\n    end\n  end\n\n  defp current_tags(%State{current_sound: %{tags: tags}}) when is_list(tags), do: tags\n  defp current_tags(_state), do: []\n\n  defp default_state, do: %State{}\n\n  defp state(socket) do\n    %State{\n      show_modal: Map.get(socket.assigns, :show_modal, false),\n      current_sound: Map.get(socket.assigns, :current_sound),\n      tag_input: Map.get(socket.assigns, :tag_input, \"\"),\n      tag_suggestions: Map.get(socket.assigns, :tag_suggestions, []),\n      show_delete_confirm: Map.get(socket.assigns, :show_delete_confirm, false),\n      edit_name_error: Map.get(socket.assigns, :edit_name_error),\n      current_user_id: socket.assigns[:current_user] && socket.assigns.current_user.id\n    }\n  end\n\n  defp update_state(socket, fun) when is_function(fun, 1) do\n    socket\n    |> state()\n    |> fun.()\n    |> then(&put_state(socket, &1))\n  end\n\n  defp put_state(socket, %State{} = state) do\n    socket\n    |> assign(:edit_state, state)\n    |> assign(:show_modal, state.show_modal)\n    |> assign(:current_sound, state.current_sound)\n    |> assign(:tag_input, state.tag_input)\n    |> assign(:tag_suggestions, state.tag_suggestions)\n    |> assign(:show_delete_confirm, state.show_delete_confirm)\n    |> assign(:edit_name_error, state.edit_name_error)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live/upload_flow.ex",
    "content": "defmodule SoundboardWeb.Live.SoundboardLive.UploadFlow do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 3]\n\n  alias Soundboard.{Sounds, Volume}\n  alias SoundboardWeb.Live.Support.TagForm\n\n  @tag_form %{input_key: :upload_tag_input, suggestions_key: :upload_tag_suggestions}\n\n  defmodule State do\n    @moduledoc false\n\n    defstruct show_upload_modal: false,\n              source_type: \"local\",\n              upload_name: \"\",\n              url: \"\",\n              upload_tags: [],\n              upload_tag_input: \"\",\n              upload_tag_suggestions: [],\n              is_join_sound: false,\n              is_leave_sound: false,\n              upload_error: nil,\n              upload_volume: 100,\n              current_user: nil,\n              audio_entries: [],\n              current_upload: nil\n\n    @type t :: %__MODULE__{\n            show_upload_modal: boolean(),\n            source_type: String.t(),\n            upload_name: String.t(),\n            url: String.t(),\n            upload_tags: list(),\n            upload_tag_input: String.t(),\n            upload_tag_suggestions: list(),\n            is_join_sound: boolean(),\n            is_leave_sound: boolean(),\n            upload_error: String.t() | nil,\n            upload_volume: number(),\n            current_user: term(),\n            audio_entries: list(),\n            current_upload: map() | nil\n          }\n  end\n\n  def assign_defaults(socket), do: put_state(socket, default_state())\n\n  def change_source_type(socket, source_type) do\n    {:noreply, update_state(socket, &%{&1 | source_type: source_type})}\n  end\n\n  def save(socket, params, consume_uploaded_entries_fn) do\n    upload = state(socket)\n\n    case upload.source_type do\n      \"url\" ->\n        case Sounds.create_sound(build_request(upload, params)) do\n          {:ok, _sound} ->\n            {:noreply,\n             socket\n             |> close_modal()\n             |> assign(:uploaded_files, Sounds.list_detailed())\n             |> Phoenix.LiveView.put_flash(:info, \"Sound added successfully\")}\n\n          {:error, changeset} ->\n            {:noreply,\n             Phoenix.LiveView.put_flash(socket, :error, Sounds.create_error_message(changeset))}\n        end\n\n      _ ->\n        results =\n          consume_uploaded_entries_fn.(socket, :audio, fn meta, entry ->\n            request =\n              upload\n              |> build_request(params)\n              |> Sounds.put_request_upload(%{path: meta.path, filename: entry.client_name})\n\n            {:ok, Sounds.create_sound(request)}\n          end)\n\n        handle_save_results(socket, results)\n    end\n  end\n\n  def validate(socket, params) do\n    upload = state(socket)\n    socket = validate_existing_entries(socket, upload)\n    upload = state(socket)\n    params = normalize_params(upload, params)\n\n    case validate_request(upload, params) do\n      :ok ->\n        {:noreply, assign_params(socket, upload, params, nil)}\n\n      {:error, changeset} ->\n        {:noreply, assign_params(socket, upload, params, Sounds.create_error_message(changeset))}\n    end\n  end\n\n  def show_modal(socket) do\n    {:noreply,\n     socket\n     |> reset_state()\n     |> update_state(&%{&1 | show_upload_modal: true})}\n  end\n\n  def hide_modal(socket), do: {:noreply, close_modal(socket)}\n\n  def close_modal(socket) do\n    socket\n    |> reset_state()\n    |> update_state(&%{&1 | show_upload_modal: false})\n  end\n\n  def add_tag(socket, key, value) do\n    upload = state(socket)\n\n    TagForm.handle_key(socket, key, value, upload.upload_tags, &append_upload_tag/3, @tag_form)\n  end\n\n  def remove_tag(socket, tag_name) do\n    {:noreply,\n     update_state(socket, fn upload ->\n       %{upload | upload_tags: Enum.reject(upload.upload_tags, &(&1.name == tag_name))}\n     end)}\n  end\n\n  def select_tag_suggestion(socket, tag_name), do: select_tag(socket, tag_name)\n\n  def update_tag_input(socket, value), do: TagForm.update_input(socket, value, @tag_form)\n\n  def select_tag(socket, tag_name) do\n    upload = state(socket)\n\n    TagForm.select_tag(socket, tag_name, upload.upload_tags, &append_upload_tag/3, @tag_form)\n  end\n\n  def toggle_join_sound(socket) do\n    {:noreply, update_state(socket, &%{&1 | is_join_sound: !&1.is_join_sound})}\n  end\n\n  def toggle_leave_sound(socket) do\n    {:noreply, update_state(socket, &%{&1 | is_leave_sound: !&1.is_leave_sound})}\n  end\n\n  def update_volume(socket, volume) do\n    {:noreply,\n     update_state(socket, fn upload ->\n       %{upload | upload_volume: Volume.normalize_percent(volume, upload.upload_volume)}\n     end)}\n  end\n\n  defp append_upload_tag(socket, tag, current_tags) do\n    {:ok, update_state(socket, &%{&1 | upload_tags: [tag | current_tags]})}\n  end\n\n  defp reset_state(socket), do: put_state(socket, default_state())\n\n  defp normalize_params(upload, params) do\n    params\n    |> Map.put_new(\"source_type\", upload.source_type)\n    |> Map.put_new(\"name\", default_upload_name(upload, params))\n    |> Map.put_new(\"url\", upload.url)\n  end\n\n  defp assign_params(socket, upload, params, error) do\n    update_state(socket, fn state ->\n      %{\n        state\n        | upload_error: error,\n          upload_name: params[\"name\"] || upload.upload_name,\n          url: params[\"url\"] || upload.url,\n          source_type: params[\"source_type\"] || upload.source_type\n      }\n    end)\n  end\n\n  defp validate_existing_entries(socket, %State{audio_entries: []}), do: socket\n\n  defp validate_existing_entries(socket, %State{} = upload) do\n    case upload.audio_entries do\n      [entry | _] ->\n        case validate_audio(entry) do\n          {:ok, _} -> socket\n          {:error, error} -> Phoenix.LiveView.put_flash(socket, :error, error)\n        end\n\n      _ ->\n        socket\n    end\n  end\n\n  defp validate_audio(entry) do\n    case entry.client_type do\n      type when type in ~w(audio/mpeg audio/wav audio/ogg audio/x-m4a) -> {:ok, entry}\n      _ -> {:error, \"Invalid file type\"}\n    end\n  end\n\n  defp validate_request(upload, %{\"source_type\" => \"url\", \"name\" => name, \"url\" => url}) do\n    if blank?(name) and blank?(url) do\n      :ok\n    else\n      case Sounds.validate_create(build_request(upload, %{\"name\" => name, \"url\" => url})) do\n        {:ok, _params} -> :ok\n        {:error, changeset} -> {:error, changeset}\n      end\n    end\n  end\n\n  defp validate_request(upload, params) do\n    request =\n      upload\n      |> build_request(params)\n      |> Sounds.put_request_upload(upload.current_upload)\n\n    case Sounds.validate_create(request) do\n      {:ok, _params} -> :ok\n      {:error, changeset} -> {:error, changeset}\n    end\n  end\n\n  defp handle_save_results(socket, [{:ok, _sound}]) do\n    {:noreply,\n     socket\n     |> close_modal()\n     |> assign(:uploaded_files, Sounds.list_detailed())\n     |> Phoenix.LiveView.put_flash(:info, \"Sound added successfully\")}\n  end\n\n  defp handle_save_results(socket, [{:error, changeset}]) do\n    {:noreply, Phoenix.LiveView.put_flash(socket, :error, Sounds.create_error_message(changeset))}\n  end\n\n  defp handle_save_results(socket, []) do\n    {:noreply,\n     Phoenix.LiveView.put_flash(\n       socket,\n       :error,\n       Sounds.create_error_message(\n         %Ecto.Changeset{}\n         |> Ecto.Changeset.change()\n         |> Ecto.Changeset.add_error(:file, \"Please select a file\")\n       )\n     )}\n  end\n\n  defp handle_save_results(socket, _results) do\n    {:noreply, Phoenix.LiveView.put_flash(socket, :error, \"Error saving file\")}\n  end\n\n  defp build_request(%State{} = upload, params) do\n    Sounds.new_create_request(upload.current_user, %{\n      source_type: upload.source_type,\n      name: params[\"name\"],\n      url: params[\"url\"],\n      tags: upload.upload_tags,\n      volume: params[\"volume\"],\n      default_volume_percent: upload.upload_volume,\n      is_join_sound: upload.is_join_sound,\n      is_leave_sound: upload.is_leave_sound\n    })\n  end\n\n  defp default_upload_name(upload, params) do\n    current_name = upload.upload_name\n    source_type = params[\"source_type\"] || upload.source_type\n    url = params[\"url\"] || upload.url\n\n    cond do\n      present?(current_name) -> current_name\n      source_type == \"local\" -> inferred_upload_name(upload.current_upload)\n      source_type == \"url\" -> inferred_url_name(url)\n      true -> \"\"\n    end\n  end\n\n  defp inferred_upload_name(%{filename: filename}) when is_binary(filename) do\n    filename\n    |> Path.basename()\n    |> Path.rootname()\n  end\n\n  defp inferred_upload_name(_), do: \"\"\n\n  defp inferred_url_name(url) when is_binary(url) do\n    url\n    |> URI.parse()\n    |> Map.get(:path, \"\")\n    |> Path.basename()\n    |> Path.rootname()\n    |> case do\n      \".\" -> \"\"\n      name -> name\n    end\n  end\n\n  defp inferred_url_name(_), do: \"\"\n\n  defp default_state, do: %State{}\n\n  defp state(socket) do\n    %State{\n      show_upload_modal: Map.get(socket.assigns, :show_upload_modal, false),\n      source_type: Map.get(socket.assigns, :source_type, \"local\"),\n      upload_name: Map.get(socket.assigns, :upload_name, \"\"),\n      url: Map.get(socket.assigns, :url, \"\"),\n      upload_tags: Map.get(socket.assigns, :upload_tags, []),\n      upload_tag_input: Map.get(socket.assigns, :upload_tag_input, \"\"),\n      upload_tag_suggestions: Map.get(socket.assigns, :upload_tag_suggestions, []),\n      is_join_sound: Map.get(socket.assigns, :is_join_sound, false),\n      is_leave_sound: Map.get(socket.assigns, :is_leave_sound, false),\n      upload_error: Map.get(socket.assigns, :upload_error),\n      upload_volume: Map.get(socket.assigns, :upload_volume, 100),\n      current_user: Map.get(socket.assigns, :current_user),\n      audio_entries: audio_entries(socket),\n      current_upload: current_upload(socket)\n    }\n  end\n\n  defp update_state(socket, fun) when is_function(fun, 1) do\n    socket\n    |> state()\n    |> fun.()\n    |> then(&put_state(socket, &1))\n  end\n\n  defp put_state(socket, %State{} = state) do\n    socket\n    |> assign(:upload_state, state)\n    |> assign(:show_upload_modal, state.show_upload_modal)\n    |> assign(:source_type, state.source_type)\n    |> assign(:upload_name, state.upload_name)\n    |> assign(:url, state.url)\n    |> assign(:upload_tags, state.upload_tags)\n    |> assign(:upload_tag_input, state.upload_tag_input)\n    |> assign(:upload_tag_suggestions, state.upload_tag_suggestions)\n    |> assign(:is_join_sound, state.is_join_sound)\n    |> assign(:is_leave_sound, state.is_leave_sound)\n    |> assign(:upload_error, state.upload_error)\n    |> assign(:upload_volume, state.upload_volume)\n  end\n\n  defp audio_entries(socket) do\n    socket.assigns\n    |> Map.get(:uploads, %{})\n    |> Map.get(:audio)\n    |> case do\n      %{entries: entries} when is_list(entries) -> entries\n      _ -> []\n    end\n  end\n\n  defp current_upload(socket) do\n    if get_in(socket.assigns, [:uploads, :audio]) do\n      case Phoenix.LiveView.uploaded_entries(socket, :audio) do\n        {[entry | _], _} -> %{filename: entry.client_name}\n        {_, [entry | _]} -> %{filename: entry.client_name}\n        _ -> nil\n      end\n    else\n      nil\n    end\n  end\n\n  defp blank?(value), do: value in [nil, \"\"]\n  defp present?(value), do: not blank?(value)\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live.ex",
    "content": "defmodule SoundboardWeb.SoundboardLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  alias SoundboardWeb.Components.Soundboard.{DeleteModal, EditModal, UploadModal}\n  import EditModal\n  import DeleteModal\n  import UploadModal\n  import SoundboardWeb.Components.Soundboard.TagComponents, only: [tag_filter_button: 1]\n  alias Soundboard.{Favorites, PubSubTopics, Sounds}\n  alias SoundboardWeb.Live.SoundboardLive.{EditFlow, UploadFlow}\n  alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}\n  alias SoundboardWeb.Soundboard.SoundFilter\n  import SoundboardWeb.Live.Support.LiveTags, only: [all_tags: 1, tag_selected?: 2]\n\n  import SoundFilter, only: [filter_sounds: 3]\n\n  @impl true\n  def mount(_params, session, socket) do\n    socket =\n      if connected?(socket) do\n        PubSubTopics.subscribe_files()\n        PubSubTopics.subscribe_playback()\n        send(self(), :load_sound_files)\n        socket\n      else\n        socket\n      end\n\n    socket =\n      socket\n      |> mount_presence(session)\n      |> assign(:current_path, \"/\")\n      |> assign(:current_user, get_user_from_session(session))\n      |> assign_initial_state()\n      |> assign_favorites(get_user_from_session(session))\n\n    if socket.assigns.flash do\n      Process.send_after(self(), :clear_flash, 3000)\n    end\n\n    {:ok, socket}\n  end\n\n  defp assign_initial_state(socket) do\n    socket\n    |> assign(:uploaded_files, [])\n    |> assign(:loading_sounds, true)\n    |> assign(:search_query, \"\")\n    |> assign(:editing, nil)\n    |> assign(:selected_tags, [])\n    |> assign(:show_all_tags, false)\n    |> UploadFlow.assign_defaults()\n    |> EditFlow.assign_defaults()\n    |> allow_upload(:audio,\n      accept: ~w(audio/mpeg audio/wav audio/ogg audio/x-m4a),\n      max_entries: 1,\n      max_file_size: 25_000_000,\n      auto_upload: false,\n      progress: &handle_progress/3,\n      accept_errors: [\n        too_large: \"File is too large (max 25MB)\",\n        not_accepted: \"Invalid file type. Please upload an MP3, WAV, OGG, or M4A file.\"\n      ]\n    )\n  end\n\n  @impl true\n  def handle_event(\"validate\", _params, socket), do: {:noreply, socket}\n\n  @impl true\n  def handle_event(\"change_source_type\", %{\"source_type\" => source_type}, socket) do\n    UploadFlow.change_source_type(socket, source_type)\n  end\n\n  @impl true\n  def handle_event(\"validate_sound\", params, socket) do\n    EditFlow.validate_sound(socket, params)\n  end\n\n  @impl true\n  def handle_event(\"toggle_tag_list\", _params, socket) do\n    {:noreply, assign(socket, :show_all_tags, !socket.assigns.show_all_tags)}\n  end\n\n  @impl true\n  def handle_event(\"play\", %{\"name\" => filename}, socket) do\n    SoundPlayback.play(socket, filename)\n  end\n\n  @impl true\n  def handle_event(\"search\", %{\"query\" => query}, socket) do\n    {:noreply, assign(socket, :search_query, query)}\n  end\n\n  @impl true\n  def handle_event(\"toggle_tag_filter\", %{\"tag\" => tag_name}, socket) do\n    case Enum.find(all_tags(socket.assigns.uploaded_files), &(&1.name == tag_name)) do\n      nil ->\n        {:noreply, socket}\n\n      tag ->\n        current_tag = List.first(socket.assigns.selected_tags)\n        selected_tags = if current_tag && current_tag.id == tag.id, do: [], else: [tag]\n\n        {:noreply,\n         socket\n         |> assign(:selected_tags, selected_tags)\n         |> assign(:search_query, \"\")}\n    end\n  end\n\n  @impl true\n  def handle_event(\"clear_tag_filters\", _, socket) do\n    {:noreply, assign(socket, :selected_tags, [])}\n  end\n\n  @impl true\n  def handle_event(\"edit\", %{\"id\" => id}, socket) do\n    EditFlow.open_modal(socket, id)\n  end\n\n  @impl true\n  def handle_event(\"save_upload\", params, socket) do\n    UploadFlow.save(socket, params, &Phoenix.LiveView.consume_uploaded_entries/3)\n  end\n\n  @impl true\n  def handle_event(\"validate_upload\", params, socket) do\n    UploadFlow.validate(socket, params)\n  end\n\n  @impl true\n  def handle_event(\"show_upload_modal\", _params, socket) do\n    UploadFlow.show_modal(socket)\n  end\n\n  @impl true\n  def handle_event(\"hide_upload_modal\", _params, socket) do\n    UploadFlow.hide_modal(socket)\n  end\n\n  @impl true\n  def handle_event(\"add_upload_tag\", %{\"key\" => key, \"value\" => value}, socket) do\n    UploadFlow.add_tag(socket, key, value)\n  end\n\n  @impl true\n  def handle_event(\"remove_upload_tag\", %{\"tag\" => tag_name}, socket) do\n    UploadFlow.remove_tag(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"select_upload_tag_suggestion\", %{\"tag\" => tag_name}, socket) do\n    UploadFlow.select_tag_suggestion(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"upload_tag_input\", %{\"key\" => _key, \"value\" => value}, socket) do\n    UploadFlow.update_tag_input(socket, value)\n  end\n\n  @impl true\n  def handle_event(\"add_tag\", %{\"key\" => key, \"value\" => value}, socket) do\n    EditFlow.add_tag(socket, key, value)\n  end\n\n  @impl true\n  def handle_event(\"remove_tag\", %{\"tag\" => tag_name}, socket) do\n    EditFlow.remove_tag(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"select_tag_suggestion\", %{\"tag\" => tag_name}, socket) do\n    EditFlow.select_tag_suggestion(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"tag_input\", %{\"key\" => _key, \"value\" => value}, socket) do\n    EditFlow.update_tag_input(socket, value)\n  end\n\n  @impl true\n  def handle_event(\"select_tag\", %{\"tag\" => tag_name}, socket) do\n    EditFlow.select_tag(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"save_sound\", params, socket) do\n    EditFlow.save_sound(socket, params)\n  end\n\n  @impl true\n  def handle_event(\"close_upload_modal\", _params, socket) do\n    UploadFlow.hide_modal(socket)\n  end\n\n  @impl true\n  def handle_event(\"close_modal\", _params, socket) do\n    {:noreply,\n     socket\n     |> UploadFlow.close_modal()\n     |> EditFlow.close_modal()}\n  end\n\n  @impl true\n  def handle_event(\"close_modal_key\", %{\"key\" => \"Escape\"}, socket) do\n    edit_open = socket.assigns[:edit_state] && socket.assigns.edit_state.show_modal\n    upload_open = socket.assigns[:upload_state] && socket.assigns.upload_state.show_upload_modal\n\n    if edit_open || upload_open do\n      handle_event(\"close_modal\", %{}, socket)\n    else\n      {:noreply, socket}\n    end\n  end\n\n  @impl true\n  def handle_event(\"select_upload_tag\", %{\"tag\" => tag_name}, socket) do\n    UploadFlow.select_tag(socket, tag_name)\n  end\n\n  @impl true\n  def handle_event(\"toggle_favorite\", %{\"sound-id\" => sound_id}, socket) do\n    case socket.assigns.current_user do\n      nil ->\n        {:noreply, put_flash(socket, :error, \"You must be logged in to favorite sounds\")}\n\n      user ->\n        case Favorites.toggle_favorite(user.id, sound_id) do\n          {:ok, _favorite} ->\n            {:noreply,\n             socket\n             |> assign_favorites(user)\n             |> put_flash(:info, \"Favorites updated!\")}\n\n          {:error, reason} ->\n            {:noreply, put_flash(socket, :error, Favorites.error_message(reason))}\n        end\n    end\n  end\n\n  @impl true\n  def handle_event(\"show_delete_confirm\", _params, socket) do\n    EditFlow.show_delete_confirm(socket)\n  end\n\n  @impl true\n  def handle_event(\"hide_delete_confirm\", _params, socket) do\n    EditFlow.hide_delete_confirm(socket)\n  end\n\n  @impl true\n  def handle_event(\"delete_sound\", _params, socket) do\n    EditFlow.delete_sound(socket)\n  end\n\n  @impl true\n  def handle_event(\"toggle_join_sound\", _params, socket) do\n    UploadFlow.toggle_join_sound(socket)\n  end\n\n  @impl true\n  def handle_event(\"toggle_leave_sound\", _params, socket) do\n    UploadFlow.toggle_leave_sound(socket)\n  end\n\n  @impl true\n  def handle_event(\"update_volume\", %{\"volume\" => volume, \"target\" => \"edit\"}, socket) do\n    EditFlow.update_volume(socket, volume)\n  end\n\n  @impl true\n  def handle_event(\"update_volume\", %{\"volume\" => volume, \"target\" => \"upload\"}, socket) do\n    UploadFlow.update_volume(socket, volume)\n  end\n\n  @impl true\n  def handle_event(\"update_volume\", _params, socket), do: {:noreply, socket}\n\n  @impl true\n  def handle_event(\"play_random\", _params, socket) do\n    filtered_sounds =\n      filter_sounds(\n        socket.assigns.uploaded_files,\n        socket.assigns.search_query,\n        socket.assigns.selected_tags\n      )\n\n    case get_random_sound(filtered_sounds) do\n      nil ->\n        {:noreply, socket}\n\n      sound ->\n        SoundPlayback.play(socket, sound.filename)\n    end\n  end\n\n  @impl true\n  def handle_event(\"stop_sound\", _params, socket) do\n    # Stop browser-based sounds\n    socket = push_event(socket, \"stop-all-sounds\", %{})\n\n    # Stop Discord bot sounds if user is logged in\n    if socket.assigns.current_user do\n      Soundboard.AudioPlayer.stop_sound()\n    end\n\n    {:noreply, socket}\n  end\n\n  @impl true\n  def handle_info({:error, message}, socket) do\n    {:noreply, put_flash(socket, :error, message)}\n  end\n\n  @impl true\n  def handle_info({:sound_played, %{filename: _, played_by: _} = event}, socket) do\n    {:noreply, FlashHelpers.flash_sound_played(socket, event)}\n  end\n\n  @impl true\n  def handle_info(:clear_flash, socket) do\n    {:noreply, clear_flash(socket)}\n  end\n\n  @impl true\n  def handle_info({:files_updated}, socket) do\n    {:noreply, load_sound_files(socket)}\n  end\n\n  @impl true\n  def handle_info(:load_sound_files, socket) do\n    {:noreply,\n     socket\n     |> load_sound_files()\n     |> assign(:loading_sounds, false)}\n  end\n\n  defp assign_favorites(socket, nil), do: assign(socket, :favorites, [])\n\n  defp assign_favorites(socket, user) do\n    favorites = Favorites.list_favorites(user.id)\n    assign(socket, :favorites, favorites)\n  end\n\n  defp load_sound_files(socket) do\n    assign(socket, :uploaded_files, Sounds.list_detailed())\n  end\n\n  defp get_random_sound([]), do: nil\n\n  defp get_random_sound(sounds) do\n    Enum.random(sounds)\n  end\n\n  defp handle_progress(:audio, _entry, socket) do\n    {:noreply, socket}\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/soundboard_live.html.heex",
    "content": "<div class=\"max-w-6xl mx-auto px-4 py-8\">\n  <div class=\"flex flex-wrap items-start justify-between gap-4 mb-8\">\n    <h1 class=\"text-3xl font-bold text-gray-800 dark:text-gray-100 flex-1 min-w-[200px]\">\n      <span class=\"group relative cursor-default inline-block\">\n        Sounds<div class=\"pointer-events-none fixed inset-0 opacity-0 group-hover:opacity-100 transition-all duration-300 z-[9999]\">\n          <img\n            src=\"/images/kubernetes.gif\"\n            alt=\"kubernetes\"\n            class=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] object-contain\"\n          />\n        </div>\n      </span>\n    </h1>\n    <div class=\"flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-end gap-2 w-full sm:w-auto\">\n      <button\n        phx-click=\"show_upload_modal\"\n        class=\"w-full sm:w-auto justify-center px-3 py-2 sm:px-4 sm:py-2 bg-blue-600 text-white font-medium rounded-md\n               hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors\n               dark:focus:ring-offset-gray-900 text-sm sm:text-base\"\n      >\n        Add Sound\n      </button>\n      <button\n        phx-click=\"play_random\"\n        class=\"w-full sm:w-auto justify-center px-3 py-2 sm:px-4 sm:py-2 rounded-md text-sm sm:text-base font-medium transition-colors\n               border border-blue-500/60 text-blue-600 dark:text-blue-300 hover:bg-blue-500/10 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900\"\n      >\n        Play Random\n      </button>\n      <button\n        phx-click=\"stop_sound\"\n        class=\"w-full sm:w-auto justify-center px-3 py-2 sm:px-4 sm:py-2 rounded-md text-sm sm:text-base font-medium transition-colors\n               border border-red-500/70 text-red-600 dark:text-red-300 hover:bg-red-500/10 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900\"\n      >\n        Stop All\n      </button>\n    </div>\n  </div>\n\n  <%!-- Search Bar --%>\n  <div class=\"mb-8\">\n    <form phx-change=\"search\" phx-submit=\"search\" class=\"relative\">\n      <input\n        type=\"text\"\n        name=\"query\"\n        value={@search_query}\n        placeholder=\"Search sounds...\"\n        autofocus\n        phx-debounce=\"400\"\n        class=\"block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm pl-4 pr-10 py-2\n               focus:border-blue-500 focus:ring-blue-500 sm:text-sm\n               dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400\"\n        autocomplete=\"off\"\n      />\n      <div class=\"absolute inset-y-0 right-0 flex items-center pr-3\">\n        <svg\n          class=\"search-icon h-5 w-5 text-gray-400 dark:text-gray-500\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 20 20\"\n          fill=\"currentColor\"\n        >\n          <path\n            fill-rule=\"evenodd\"\n            d=\"M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z\"\n            clip-rule=\"evenodd\"\n          />\n        </svg>\n        <svg\n          class=\"search-spinner hidden animate-spin h-5 w-5 text-gray-400 dark:text-gray-500\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n        >\n          <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\">\n          </circle>\n          <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z\">\n          </path>\n        </svg>\n      </div>\n    </form>\n  </div>\n  \n<!-- Tag Filter Bar -->\n  <% tags = all_tags(@uploaded_files) %>\n  <% limited_tags =\n    if @show_all_tags do\n      tags\n    else\n      Enum.take(tags, 12)\n    end %>\n  <div class=\"mb-4 flex flex-wrap gap-2 items-center sm:hidden\">\n    <%= for tag <- limited_tags do %>\n      <.tag_filter_button\n        tag={tag}\n        selected_tags={@selected_tags}\n        uploaded_files={@uploaded_files}\n      />\n    <% end %>\n    <%= if length(tags) > 12 do %>\n      <button\n        phx-click=\"toggle_tag_list\"\n        class=\"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium\n               bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600\"\n      >\n        {if @show_all_tags, do: \"Show fewer tags\", else: \"Show more tags\"}\n      </button>\n    <% end %>\n  </div>\n  <div class=\"mb-4 hidden sm:flex sm:flex-wrap sm:gap-2 sm:items-center\">\n    <%= for tag <- tags do %>\n      <.tag_filter_button\n        tag={tag}\n        selected_tags={@selected_tags}\n        uploaded_files={@uploaded_files}\n      />\n    <% end %>\n  </div>\n\n  <div class=\"flex justify-between items-center mb-2\">\n    <div class=\"text-sm text-gray-600 dark:text-gray-400\">\n      <p style=\"font-weight: bold\">Total Sounds: {length(@uploaded_files)}</p>\n    </div>\n  </div>\n  <%!-- Sound Grid --%>\n  <div class=\"grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-4\">\n    <%= if @loading_sounds do %>\n      <div class=\"col-span-full\">\n        <div class=\"loading-container\">\n          <div class=\"loading-spinner\"></div>\n        </div>\n      </div>\n    <% else %>\n      <%= for sound <- filter_sounds(@uploaded_files, @search_query, @selected_tags) do %>\n        <div class=\"relative bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all duration-200\">\n          <%!-- Make the whole card clickable --%>\n          <button\n            phx-click=\"play\"\n            phx-value-name={sound.filename}\n            class=\"absolute inset-0 w-full h-full cursor-pointer z-0\n                   hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg\n                   focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2\n                   dark:focus:ring-offset-gray-800\"\n          >\n          </button>\n\n          <%!-- Content container with higher z-index --%>\n          <div class=\"relative z-10 p-4 flex flex-col min-h-[120px] pointer-events-none\">\n            <%!-- Sound title and uploader info --%>\n            <div class=\"flex-1 min-w-0\">\n              <div class=\"text-gray-800 dark:text-gray-200 font-medium break-all\">\n                {display_name(sound.filename)}\n              </div>\n              <%= if sound.user && sound.user.username do %>\n                <div class=\"text-xs text-gray-500 dark:text-gray-400\">\n                  Uploaded by {sound.user.username}\n                </div>\n              <% end %>\n            </div>\n\n            <%!-- Bottom Section with Tags and Icons --%>\n            <div class=\"mt-auto pt-2 flex justify-between items-center\">\n              <%!-- Tags on the left --%>\n              <div class=\"flex flex-wrap gap-1 items-center pointer-events-auto z-20\">\n                <%= if sound.tags != [] do %>\n                  <%= for tag <- sound.tags do %>\n                    <button\n                      phx-click=\"toggle_tag_filter\"\n                      phx-value-tag={tag.name}\n                      class={[\n                        \"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium transition-colors\",\n                        if(tag_selected?(tag, @selected_tags),\n                          do: \"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300\",\n                          else:\n                            \"bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/50 dark:text-blue-300 dark:hover:bg-blue-900\"\n                        )\n                      ]}\n                    >\n                      {tag.name}\n                    </button>\n                  <% end %>\n                <% end %>\n              </div>\n\n              <%!-- Icons on the right --%>\n              <div class=\"flex items-center gap-2 ml-2 flex-shrink-0 pointer-events-auto\">\n                <button\n                  id={\"local-play-#{Path.basename(sound.filename, Path.extname(sound.filename))}\"}\n                  phx-hook=\"LocalPlayer\"\n                  data-filename={sound.filename}\n                  data-source-type={sound.source_type}\n                  data-url={sound.url}\n                  data-volume={sound.volume || 1.0}\n                  class=\"relative flex items-center justify-center w-8 h-8 text-gray-400 hover:text-green-600 \n                         dark:text-gray-500 dark:hover:text-green-400 rounded-md transition-colors\"\n                >\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"currentColor\"\n                    class=\"w-4 h-4 play-icon\"\n                  >\n                    <path d=\"M12 15a3 3 0 100-6 3 3 0 000 6z\" />\n                    <path\n                      fill-rule=\"evenodd\"\n                      d=\"M1.323 11.447C2.811 6.976 7.028 3.75 12.001 3.75c4.97 0 9.185 3.223 10.675 7.69.12.362.12.752 0 1.113-1.487 4.471-5.705 7.697-10.677 7.697-4.97 0-9.186-3.223-10.675-7.69a1.762 1.762 0 010-1.113zM17.25 12a5.25 5.25 0 11-10.5 0 5.25 5.25 0 0110.5 0z\"\n                      clip-rule=\"evenodd\"\n                    />\n                  </svg>\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"currentColor\"\n                    class=\"w-4 h-4 stop-icon hidden\"\n                  >\n                    <path\n                      fill-rule=\"evenodd\"\n                      d=\"M4.5 7.5a3 3 0 013-3h9a3 3 0 013 3v9a3 3 0 01-3 3h-9a3 3 0 01-3-3v-9z\"\n                      clip-rule=\"evenodd\"\n                    />\n                  </svg>\n                </button>\n                <%= if @current_user do %>\n                  <button\n                    phx-click=\"toggle_favorite\"\n                    phx-value-sound-id={sound.id}\n                    class=\"relative flex items-center justify-center w-8 h-8 text-gray-400 hover:text-red-500 \n                           dark:text-gray-500 dark:hover:text-red-500 rounded-md transition-colors\"\n                  >\n                    <%= if sound.id in @favorites do %>\n                      <.icon name=\"hero-heart-solid\" class=\"w-4 h-4 text-red-500\" />\n                    <% else %>\n                      <.icon name=\"hero-heart\" class=\"w-4 h-4\" />\n                    <% end %>\n                  </button>\n                <% end %>\n                <button\n                  phx-click=\"edit\"\n                  phx-value-id={sound.id}\n                  class=\"relative flex items-center justify-center w-8 h-8 text-gray-400 hover:text-blue-600 \n                         dark:text-gray-500 dark:hover:text-blue-400 rounded-md\"\n                >\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    fill=\"none\"\n                    viewBox=\"0 0 24 24\"\n                    stroke-width=\"1.5\"\n                    stroke=\"currentColor\"\n                    class=\"w-4 h-4\"\n                  >\n                    <path\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      d=\"m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10\"\n                    />\n                  </svg>\n                </button>\n              </div>\n            </div>\n          </div>\n        </div>\n      <% end %>\n    <% end %>\n  </div>\n\n  <%= if @show_modal do %>\n    <.edit_modal\n      flash={@flash}\n      edit_name_error={@edit_name_error}\n      current_user={@current_user}\n      current_sound={@current_sound}\n      tag_input={@tag_input}\n      tag_suggestions={@tag_suggestions}\n    />\n    <%= if @show_delete_confirm do %>\n      <.delete_modal {assigns} />\n    <% end %>\n  <% end %>\n\n  <%= if @show_upload_modal do %>\n    <.upload_modal {assigns} />\n  <% end %>\n\n  <script>\n    window.addEventListener(\"phx:clear-tag-input\", (e) => {\n      document.getElementById(\"tag-input\").value = \"\";\n    })\n\n    window.addEventListener(\"phx:stop-all-sounds\", (e) => {\n      // Stop all audio elements\n      document.querySelectorAll('audio').forEach(audio => {\n        audio.pause();\n        audio.currentTime = 0;\n      });\n      \n      // Reset all play/stop icons\n      document.querySelectorAll('.play-icon').forEach(icon => {\n        icon.classList.remove('hidden');\n      });\n      document.querySelectorAll('.stop-icon').forEach(icon => {\n        icon.classList.add('hidden');\n      });\n    });\n  </script>\n</div>\n"
  },
  {
    "path": "lib/soundboard_web/live/stats_live.ex",
    "content": "defmodule SoundboardWeb.StatsLive do\n  use SoundboardWeb, :live_view\n  use SoundboardWeb.Live.Support.PresenceLive\n  alias SoundboardWeb.PresenceHandler\n  import Phoenix.Component\n  import SoundboardWeb.SoundHelpers\n  alias Soundboard.{Accounts, Favorites, PubSubTopics, Sounds, Stats}\n  alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}\n  import FlashHelpers, only: [clear_flash_after_timeout: 1]\n  require Logger\n\n  @recent_limit 5\n\n  @impl true\n  def mount(_params, session, socket) do\n    if connected?(socket) do\n      :timer.send_interval(60 * 60 * 1000, self(), :check_week_rollover)\n      PubSubTopics.subscribe_playback()\n      PubSubTopics.subscribe_stats()\n    end\n\n    current_week = get_week_range()\n\n    {:ok,\n     socket\n     |> mount_presence(session)\n     |> assign(:current_path, \"/stats\")\n     |> assign(:current_user, get_user_from_session(session))\n     |> assign(:force_update, 0)\n     |> assign(:selected_week, current_week)\n     |> assign(:current_week, current_week)\n     |> stream_configure(:recent_plays, dom_id: &recent_play_dom_id/1)\n     |> stream(:recent_plays, [])\n     |> assign_stats()}\n  end\n\n  @impl true\n  def handle_info({:sound_played, %{filename: filename, played_by: username}}, socket) do\n    recent_plays = recent_plays()\n\n    {:noreply,\n     socket\n     |> stream(:recent_plays, recent_plays, reset: true)\n     |> put_flash(:info, \"#{username} played #{display_name(filename)}\")\n     |> clear_flash_after_timeout()}\n  end\n\n  @impl true\n  def handle_info({:stats_updated}, socket) do\n    {:noreply, assign_stats(socket)}\n  end\n\n  @impl true\n  def handle_info({:error, message}, socket) do\n    {:noreply,\n     socket\n     |> put_flash(:error, message)\n     |> clear_flash_after_timeout()}\n  end\n\n  @impl true\n  def handle_info(:clear_flash, socket) do\n    {:noreply, clear_flash(socket)}\n  end\n\n  defp assign_stats(socket) do\n    {start_date, end_date} = socket.assigns.selected_week\n    top_users = Stats.get_top_users(start_date, end_date, limit: @recent_limit)\n    top_sounds = Stats.get_top_sounds(start_date, end_date, limit: @recent_limit)\n\n    recent_plays = recent_plays()\n\n    recent_uploads = Sounds.get_recent_uploads(limit: @recent_limit)\n    favorites = get_favorites(socket.assigns.current_user)\n    sound_ids_by_filename = load_sound_ids_by_filename(top_sounds, recent_plays, recent_uploads)\n    avatars_by_username = load_avatars_by_username(top_users, recent_plays, recent_uploads)\n\n    socket\n    |> assign(:top_users, top_users)\n    |> assign(:top_sounds, top_sounds)\n    |> stream(:recent_plays, recent_plays, reset: true)\n    |> assign(:recent_uploads, recent_uploads)\n    |> assign(:favorites, favorites)\n    |> assign(:sound_ids_by_filename, sound_ids_by_filename)\n    |> assign(:avatars_by_username, avatars_by_username)\n  end\n\n  defp get_favorites(nil), do: []\n  defp get_favorites(user), do: Favorites.list_favorites(user.id)\n\n  defp format_timestamp(timestamp) do\n    timestamp\n    |> DateTime.from_naive!(\"Etc/UTC\")\n    |> Calendar.strftime(\"%b %d, %I:%M %p UTC\")\n  end\n\n  defp get_week_range(date \\\\ Date.utc_today()) do\n    days_since_monday = Date.day_of_week(date, :monday)\n    start_date = Date.add(date, -days_since_monday + 1)\n    end_date = Date.add(start_date, 6)\n    {start_date, end_date}\n  end\n\n  defp format_date_range({start_date, end_date}) do\n    \"#{Calendar.strftime(start_date, \"%b %d\")} - #{Calendar.strftime(end_date, \"%b %d, %Y\")}\"\n  end\n\n  defp date_input_value({start_date, _end_date}) do\n    Date.to_iso8601(start_date)\n  end\n\n  defp parse_week_input(nil), do: :error\n  defp parse_week_input(\"\"), do: :error\n\n  defp parse_week_input(week_value) do\n    case Date.from_iso8601(week_value) do\n      {:ok, date} -> {:ok, get_week_range(date)}\n      _ -> :error\n    end\n  end\n\n  @impl true\n  def render(assigns) do\n    ~H\"\"\"\n    <div id=\"stats\" class=\"max-w-6xl mx-auto px-4 py-8\">\n      <div class=\"flex justify-between items-center mb-8\">\n        <h1 class=\"text-3xl font-bold text-gray-800 dark:text-gray-100\">Stats</h1>\n        <div class=\"flex items-center gap-4\">\n          <button\n            phx-click=\"previous_week\"\n            class=\"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200\"\n          >\n            <.icon name=\"hero-chevron-left-solid\" class=\"h-5 w-5\" />\n          </button>\n          <div class=\"flex flex-col items-start gap-1\">\n            <form\n              phx-change=\"select_week\"\n              phx-submit=\"select_week\"\n              class=\"flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400\"\n            >\n              <label for=\"week-picker\" class=\"whitespace-nowrap\">\n                Week of\n              </label>\n              <input\n                type=\"date\"\n                id=\"week-picker\"\n                name=\"week\"\n                value={date_input_value(@selected_week)}\n                max={date_input_value(@current_week)}\n                phx-debounce=\"blur\"\n                class=\"border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500\"\n              />\n            </form>\n            <span class=\"text-xs text-gray-500 dark:text-gray-400\">\n              {format_date_range(@selected_week)}\n            </span>\n          </div>\n          <button\n            phx-click=\"next_week\"\n            disabled={@selected_week == @current_week}\n            class={[\n              \"text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200\",\n              @selected_week == @current_week && \"opacity-50 cursor-not-allowed\"\n            ]}\n          >\n            <.icon name=\"hero-chevron-right-solid\" class=\"h-5 w-5\" />\n          </button>\n        </div>\n      </div>\n\n      <div class=\"grid grid-cols-1 md:grid-cols-2 gap-8\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow p-6\">\n          <h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-100 mb-4\">Top Users</h2>\n          <div class=\"space-y-2\">\n            <%= for {username, count} <- @top_users do %>\n              <div class=\"flex justify-between items-center\" id={\"user-stat-#{username}\"}>\n                <span class={[\n                  \"px-2 py-1 rounded-full text-sm flex items-center gap-1\",\n                  get_user_color_from_presence(username, @presences)\n                ]}>\n                  <img\n                    :if={get_user_avatar(username, @presences, @avatars_by_username)}\n                    src={get_user_avatar(username, @presences, @avatars_by_username)}\n                    class=\"w-4 h-4 rounded-full\"\n                    alt={\"#{username}'s avatar\"}\n                  />\n                  {username}\n                </span>\n                <span class=\"text-gray-600 dark:text-gray-400\">{count} plays</span>\n              </div>\n            <% end %>\n          </div>\n        </div>\n\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6\">\n          <h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4\">Top Sounds</h2>\n          <div class=\"space-y-3\">\n            <%= for {sound_name, count} <- @top_sounds do %>\n              <div\n                class=\"flex items-center justify-between p-2 px-6 rounded-lg bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer group\"\n                id={\"play-top-#{sound_name}\"}\n                phx-click=\"play_sound\"\n                phx-value-sound={sound_name}\n              >\n                <div class=\"flex items-center gap-3 min-w-0\">\n                  <div class=\"min-w-0\">\n                    <p class=\"text-sm font-medium text-gray-900 dark:text-gray-100 truncate\">\n                      {display_name(sound_name)}\n                    </p>\n                    <p class=\"text-xs text-gray-500 dark:text-gray-400\">\n                      {count} plays\n                    </p>\n                  </div>\n                </div>\n                <div class=\"flex items-center gap-2\">\n                  <button\n                    phx-click=\"toggle_favorite\"\n                    phx-value-sound={sound_name}\n                    phx-stop\n                    id={\"favorite-#{sound_name}\"}\n                    class=\"text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-500 mr-2\"\n                  >\n                    <%= if favorite?(@favorites, sound_name, @sound_ids_by_filename) do %>\n                      <.icon name=\"hero-heart-solid\" class=\"h-5 w-5 text-red-500\" />\n                    <% else %>\n                      <.icon name=\"hero-heart\" class=\"h-5 w-5\" />\n                    <% end %>\n                  </button>\n                </div>\n              </div>\n            <% end %>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"mt-8 grid grid-cols-1 md:grid-cols-2 gap-8\">\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6\">\n          <h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4\">Recent Plays</h2>\n          <div class=\"space-y-3\" id=\"recent_plays\" phx-update=\"stream\">\n            <%= for {dom_id, play} <- @streams.recent_plays do %>\n              <div\n                class=\"flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer group\"\n                id={dom_id}\n                phx-click=\"play_sound\"\n                phx-value-sound={play.filename}\n              >\n                <div class=\"flex items-center gap-3 min-w-0\">\n                  <div class=\"flex-shrink-0\">\n                    <img\n                      src={get_user_avatar(play.username, @presences, @avatars_by_username)}\n                      class=\"w-8 h-8 rounded-full\"\n                      alt={play.username}\n                    />\n                  </div>\n                  <div class=\"min-w-0\">\n                    <p class=\"text-sm font-medium text-gray-900 dark:text-gray-100 truncate\">\n                      {display_name(play.filename)}\n                    </p>\n                    <p class=\"text-xs text-gray-500 dark:text-gray-400\">\n                      {play.username}\n                    </p>\n                  </div>\n                </div>\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                    {format_timestamp(play.timestamp)}\n                  </span>\n                  <button\n                    phx-click=\"toggle_favorite\"\n                    phx-value-sound={play.filename}\n                    phx-stop\n                    class=\"text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-500 mr-2\"\n                  >\n                    <%= if favorite?(@favorites, play.filename, @sound_ids_by_filename) do %>\n                      <.icon name=\"hero-heart-solid\" class=\"h-5 w-5 text-red-500\" />\n                    <% else %>\n                      <.icon name=\"hero-heart\" class=\"h-5 w-5\" />\n                    <% end %>\n                  </button>\n                </div>\n              </div>\n            <% end %>\n          </div>\n        </div>\n\n        <div class=\"bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6\">\n          <h2 class=\"text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4\">\n            Recently Uploaded\n          </h2>\n          <div class=\"space-y-3\">\n            <%= for {sound_name, username, timestamp} <- @recent_uploads do %>\n              <div\n                class=\"flex items-center justify-between p-2 px-6 rounded-lg bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer group\"\n                id={\"play-upload-#{sound_name}\"}\n                phx-click=\"play_sound\"\n                phx-value-sound={sound_name}\n              >\n                <div class=\"flex items-center gap-3 min-w-0\">\n                  <div class=\"flex-shrink-0\">\n                    <img\n                      src={get_user_avatar(username, @presences, @avatars_by_username)}\n                      class=\"w-8 h-8 rounded-full\"\n                      alt={username}\n                    />\n                  </div>\n                  <div class=\"min-w-0\">\n                    <p class=\"text-sm font-medium text-gray-900 dark:text-gray-100 truncate\">\n                      {display_name(sound_name)}\n                    </p>\n                    <p class=\"text-xs text-gray-500 dark:text-gray-400\">\n                      {username}\n                    </p>\n                  </div>\n                </div>\n                <div class=\"flex items-center gap-2\">\n                  <span class=\"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap\">\n                    {format_timestamp(timestamp)}\n                  </span>\n                  <button\n                    phx-click=\"toggle_favorite\"\n                    phx-value-sound={sound_name}\n                    phx-stop\n                    class=\"text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-500 mr-2\"\n                  >\n                    <%= if favorite?(@favorites, sound_name, @sound_ids_by_filename) do %>\n                      <.icon name=\"hero-heart-solid\" class=\"h-5 w-5 text-red-500\" />\n                    <% else %>\n                      <.icon name=\"hero-heart\" class=\"h-5 w-5\" />\n                    <% end %>\n                  </button>\n                </div>\n              </div>\n            <% end %>\n          </div>\n        </div>\n      </div>\n    </div>\n    \"\"\"\n  end\n\n  defp get_user_color_from_presence(username, presences) do\n    presences\n    |> Enum.find_value(fn {_id, presence} ->\n      meta = List.first(presence.metas)\n\n      if get_in(meta, [:user, :username]) == username do\n        get_in(meta, [:user, :color]) ||\n          PresenceHandler.get_user_color(username)\n      end\n    end) || PresenceHandler.get_user_color(username)\n  end\n\n  defp handle_favorite_toggle(socket, user, sound_name) do\n    case Sounds.fetch_sound_id(sound_name) do\n      {:ok, sound_id} -> update_favorite(socket, user, sound_id)\n      :error -> {:noreply, put_flash(socket, :error, \"Sound not found\")}\n    end\n  end\n\n  defp update_favorite(socket, user, sound_id) do\n    case Favorites.toggle_favorite(user.id, sound_id) do\n      {:ok, _favorite} ->\n        updated_favorites = Favorites.list_favorites(user.id)\n        recent_plays = recent_plays()\n\n        {:noreply,\n         socket\n         |> assign(:favorites, updated_favorites)\n         |> stream(:recent_plays, recent_plays, reset: true)\n         |> put_flash(:info, \"Favorites updated!\")}\n\n      {:error, reason} ->\n        {:noreply, put_flash(socket, :error, Favorites.error_message(reason))}\n    end\n  end\n\n  defp recent_plays do\n    Stats.get_recent_plays(limit: @recent_limit)\n    |> Enum.map(&map_recent_play/1)\n  end\n\n  defp map_recent_play({id, filename, username, timestamp}) do\n    %{\n      id: id,\n      filename: filename,\n      username: username,\n      timestamp: timestamp\n    }\n  end\n\n  defp load_sound_ids_by_filename(top_sounds, recent_plays, recent_uploads) do\n    filenames =\n      top_sounds\n      |> Enum.map(fn {filename, _count} -> filename end)\n      |> Kernel.++(Enum.map(recent_plays, & &1.filename))\n      |> Kernel.++(Enum.map(recent_uploads, fn {filename, _username, _timestamp} -> filename end))\n      |> Enum.uniq()\n\n    case filenames do\n      [] ->\n        %{}\n\n      _ ->\n        Sounds.ids_by_filename(filenames)\n    end\n  end\n\n  defp load_avatars_by_username(top_users, recent_plays, recent_uploads) do\n    usernames =\n      top_users\n      |> Enum.map(fn {username, _count} -> username end)\n      |> Kernel.++(Enum.map(recent_plays, & &1.username))\n      |> Kernel.++(Enum.map(recent_uploads, fn {_filename, username, _timestamp} -> username end))\n      |> Enum.uniq()\n\n    case usernames do\n      [] ->\n        %{}\n\n      _ ->\n        Accounts.avatars_by_usernames(usernames)\n    end\n  end\n\n  defp recent_play_dom_id(play) do\n    base = slugify(play.filename)\n    \"recent-play-#{base}-#{play.id}\"\n  end\n\n  @impl true\n  def handle_event(\"play_sound\", %{\"sound\" => sound_name}, socket) do\n    SoundPlayback.play(socket, sound_name)\n  end\n\n  @impl true\n  def handle_event(\"toggle_favorite\", %{\"sound\" => sound_name}, socket) do\n    case socket.assigns.current_user do\n      nil ->\n        {:noreply, put_flash(socket, :error, \"You must be logged in to favorite sounds\")}\n\n      user ->\n        handle_favorite_toggle(socket, user, sound_name)\n    end\n  end\n\n  @impl true\n  def handle_event(\"previous_week\", _, socket) do\n    {start_date, _} = socket.assigns.selected_week\n    new_week = get_week_range(Date.add(start_date, -7))\n\n    {:noreply,\n     socket\n     |> assign(:selected_week, new_week)\n     |> assign_stats()}\n  end\n\n  @impl true\n  def handle_event(\"next_week\", _, socket) do\n    {start_date, _} = socket.assigns.selected_week\n    new_week = get_week_range(Date.add(start_date, 7))\n\n    case Date.compare(elem(new_week, 1), elem(socket.assigns.current_week, 1)) do\n      :gt -> {:noreply, socket}\n      _ -> {:noreply, socket |> assign(:selected_week, new_week) |> assign_stats()}\n    end\n  end\n\n  @impl true\n  def handle_event(\"select_week\", %{\"week\" => week_value}, socket) do\n    current_week = socket.assigns.current_week\n\n    case parse_week_input(week_value) do\n      {:ok, new_week} ->\n        if Date.compare(elem(new_week, 1), elem(current_week, 1)) == :gt do\n          {:noreply, socket}\n        else\n          {:noreply,\n           socket\n           |> assign(:selected_week, new_week)\n           |> assign_stats()}\n        end\n\n      :error ->\n        {:noreply, socket}\n    end\n  end\n\n  defp favorite?(favorites, sound_name, sound_ids_by_filename) do\n    case Map.get(sound_ids_by_filename, sound_name) do\n      nil -> false\n      sound_id -> Enum.member?(favorites, sound_id)\n    end\n  end\n\n  defp get_user_avatar(username, presences, avatars_by_username) do\n    presences\n    |> Enum.find_value(fn {_id, presence} ->\n      meta = List.first(presence.metas)\n      if get_in(meta, [:user, :username]) == username, do: get_in(meta, [:user, :avatar])\n    end) || Map.get(avatars_by_username, username)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/support/flash_helpers.ex",
    "content": "defmodule SoundboardWeb.Live.Support.FlashHelpers do\n  @moduledoc false\n\n  import Phoenix.LiveView, only: [put_flash: 3]\n\n  def flash_sound_played(socket, %{filename: filename, played_by: username}) do\n    socket\n    |> put_flash(:info, \"#{username} played #{filename}\")\n    |> clear_flash_after_timeout()\n  end\n\n  def clear_flash_after_timeout(socket) do\n    Process.send_after(self(), :clear_flash, 3000)\n    socket\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/support/live_tags.ex",
    "content": "defmodule SoundboardWeb.Live.Support.LiveTags do\n  @moduledoc \"\"\"\n  LiveView-facing tag queries and mutations for the soundboard.\n  \"\"\"\n\n  alias Soundboard.{PubSubTopics, Sounds.Tags}\n\n  def add_tag(tag_name, current_tags, apply_tag_fun) when is_function(apply_tag_fun, 2) do\n    with {:ok} <- validate_tag_name(tag_name),\n         {:ok, tag} <- Tags.find_or_create(tag_name),\n         {:ok} <- validate_unique_tag(tag, current_tags) do\n      apply_tag_fun.(tag, current_tags)\n    end\n  end\n\n  def search(query), do: Tags.search(query)\n  def all_tags(sounds), do: Tags.all_for_sounds(sounds)\n  def count_sounds_with_tag(sounds, tag), do: Tags.count_sounds_with_tag(sounds, tag)\n  def tag_selected?(tag, selected_tags), do: Tags.tag_selected?(tag, selected_tags)\n  def update_sound_tags(sound, tags), do: Tags.update_sound_tags(sound, tags)\n  def find_or_create_tag(name), do: Tags.find_or_create(name)\n  def list_tags_for_sound(filename), do: Tags.list_for_sound(filename)\n\n  def broadcast_update do\n    PubSubTopics.broadcast_files_updated()\n  end\n\n  defp validate_tag_name(tag_name) do\n    if String.trim(to_string(tag_name)) == \"\" do\n      {:error, \"Tag name cannot be empty\"}\n    else\n      {:ok}\n    end\n  end\n\n  defp validate_unique_tag(tag, current_tags) do\n    if Enum.any?(current_tags, &(&1.id == tag.id)) do\n      {:error, \"Tag already exists\"}\n    else\n      {:ok}\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/support/presence_live.ex",
    "content": "defmodule SoundboardWeb.Live.Support.PresenceLive do\n  defmacro __using__(_opts) do\n    quote do\n      alias SoundboardWeb.{Presence, PresenceHandler}\n      require Logger\n\n      @presence_topic \"soundboard:presence\"\n\n      def mount_presence(socket, session) do\n        if connected?(socket) do\n          user = get_user_from_session(session)\n\n          Phoenix.PubSub.subscribe(Soundboard.PubSub, @presence_topic)\n          Process.put(:connected_pid, self())\n          Process.put(:socket_id, socket.id)\n          Process.put(:current_user, user)\n\n          if user do\n            {:ok, _} =\n              Presence.track(self(), @presence_topic, socket.id, %{\n                user: %{\n                  username: user.username,\n                  avatar: user.avatar\n                },\n                online_at: System.system_time(:second)\n              })\n          end\n        end\n\n        socket\n        |> assign(:presences, Presence.list(@presence_topic))\n        |> assign(:presence_count, map_size(Presence.list(@presence_topic)))\n      end\n\n      defp get_user_from_session(%{\"user_id\" => user_id}),\n        do: Soundboard.Repo.get(Soundboard.Accounts.User, user_id)\n\n      defp get_user_from_session(_), do: nil\n\n      @impl true\n      def handle_info({:presence_update, presences}, socket) do\n        {:noreply, assign(socket, :presences, presences)}\n      end\n\n      @impl true\n      def handle_info({:presence_diff, diff}, socket) do\n        {:noreply,\n         assign(socket,\n           presence_count:\n             PresenceHandler.handle_presence_diff(\n               diff,\n               socket.assigns.presence_count\n             )\n         )}\n      end\n\n      @impl true\n      def handle_info(%Phoenix.Socket.Broadcast{event: \"presence_diff\", payload: diff}, socket) do\n        presences = Presence.list(@presence_topic)\n\n        {:noreply,\n         socket\n         |> assign(:presences, presences)\n         |> assign(\n           :presence_count,\n           PresenceHandler.handle_presence_diff(diff, socket.assigns.presence_count)\n         )}\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/support/sound_playback.ex",
    "content": "defmodule SoundboardWeb.Live.Support.SoundPlayback do\n  @moduledoc false\n\n  import Phoenix.LiveView, only: [put_flash: 3]\n\n  alias Soundboard.Accounts.User\n\n  def play(socket, sound_name) do\n    case socket.assigns[:current_user] do\n      %User{} = user ->\n        Soundboard.AudioPlayer.play_sound(sound_name, user)\n        {:noreply, socket}\n\n      _ ->\n        {:noreply, put_flash(socket, :error, \"You must be logged in to play sounds\")}\n    end\n  end\n\n  def current_username(socket) do\n    case socket.assigns[:current_user] do\n      %User{username: username} -> {:ok, username}\n      _ -> :error\n    end\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/live/support/tag_form.ex",
    "content": "defmodule SoundboardWeb.Live.Support.TagForm do\n  @moduledoc false\n\n  import Phoenix.Component, only: [assign: 3]\n  import Phoenix.LiveView, only: [put_flash: 3]\n\n  alias SoundboardWeb.Live.Support.LiveTags\n\n  @type config :: %{required(:input_key) => atom(), required(:suggestions_key) => atom()}\n\n  def handle_key(socket, key, value, current_tags, apply_tag_fun, config)\n      when is_function(apply_tag_fun, 3) and is_map(config) do\n    if key == \"Enter\" and value != \"\" do\n      select_tag(socket, value, current_tags, apply_tag_fun, config)\n    else\n      update_input(socket, value, config)\n    end\n  end\n\n  def select_tag(socket, tag_name, current_tags, apply_tag_fun, config)\n      when is_function(apply_tag_fun, 3) and is_map(config) do\n    tag_name\n    |> LiveTags.add_tag(current_tags, fn tag, tags -> apply_tag_fun.(socket, tag, tags) end)\n    |> handle_result(socket, config)\n  end\n\n  def update_input(socket, value, %{input_key: input_key, suggestions_key: suggestions_key}) do\n    suggestions = LiveTags.search(value)\n\n    {:noreply,\n     socket\n     |> assign(input_key, value)\n     |> assign(suggestions_key, suggestions)}\n  end\n\n  defp handle_result({:ok, updated_socket}, _socket, config) do\n    {:noreply, reset(updated_socket, config)}\n  end\n\n  defp handle_result({:error, message}, socket, config) do\n    {:noreply,\n     socket\n     |> reset(config)\n     |> put_flash(:error, message)}\n  end\n\n  defp reset(socket, %{input_key: input_key, suggestions_key: suggestions_key}) do\n    socket\n    |> assign(input_key, \"\")\n    |> assign(suggestions_key, [])\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/plugs/api_auth.ex",
    "content": "defmodule SoundboardWeb.Plugs.APIAuth do\n  @moduledoc \"\"\"\n  API authentication plug.\n  \"\"\"\n  import Plug.Conn\n  alias Soundboard.Accounts.ApiTokens\n\n  def init(opts), do: opts\n\n  def call(conn, _opts) do\n    case get_req_header(conn, \"authorization\") do\n      [\"Bearer \" <> token] ->\n        authenticate_with_token(conn, token)\n\n      _ ->\n        unauthorized(conn)\n    end\n  end\n\n  defp authenticate_with_token(conn, token) do\n    case verify_db_token(token) do\n      {:ok, user, api_token} ->\n        conn\n        |> assign(:current_user, user)\n        |> assign(:api_token, api_token)\n\n      {:error, :invalid} ->\n        unauthorized(conn)\n\n      {:error, :token_update_failed} ->\n        internal_error(conn)\n    end\n  end\n\n  defp unauthorized(conn, message \\\\ \"Invalid API token\") do\n    conn\n    |> put_status(:unauthorized)\n    |> Phoenix.Controller.json(%{error: message})\n    |> halt()\n  end\n\n  defp internal_error(conn) do\n    conn\n    |> put_status(:internal_server_error)\n    |> Phoenix.Controller.json(%{error: \"API token verification failed\"})\n    |> halt()\n  end\n\n  defp verify_db_token(token), do: ApiTokens.verify_token(token)\nend\n"
  },
  {
    "path": "lib/soundboard_web/plugs/basic_auth.ex",
    "content": "defmodule SoundboardWeb.Plugs.BasicAuth do\n  @moduledoc \"\"\"\n  Basic authentication plug.\n\n  When both `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables\n  are set to non-blank values, every browser request must supply matching Basic\n  credentials. When either variable is missing or blank, basic auth is disabled\n  and all requests pass through.\n  \"\"\"\n  import Plug.Conn\n  require Logger\n\n  def init(opts), do: opts\n\n  def call(conn, _opts) do\n    username = credential(\"BASIC_AUTH_USERNAME\")\n    password = credential(\"BASIC_AUTH_PASSWORD\")\n\n    case {username, password} do\n      {nil, nil} ->\n        conn\n\n      {username, password} when is_binary(username) and is_binary(password) ->\n        authenticate(conn, username, password)\n\n      _ ->\n        Logger.warning(\"Basic auth is partially configured; failing closed\")\n        unauthorized(conn)\n    end\n  end\n\n  defp credential(key) do\n    case System.get_env(key) do\n      nil ->\n        nil\n\n      value when is_binary(value) ->\n        if String.trim(value) == \"\" do\n          nil\n        else\n          value\n        end\n    end\n  end\n\n  defp authenticate(conn, username, password) do\n    with [\"Basic \" <> auth] <- get_req_header(conn, \"authorization\"),\n         {:ok, decoded} <- Base.decode64(auth),\n         {provided_username, provided_password} <- split_credentials(decoded),\n         true <- provided_username == username and provided_password == password do\n      conn\n    else\n      _ -> unauthorized(conn)\n    end\n  end\n\n  defp split_credentials(decoded) do\n    case String.split(decoded, \":\", parts: 2) do\n      [username, password] -> {username, password}\n      _ -> :error\n    end\n  end\n\n  defp unauthorized(conn) do\n    conn\n    |> put_resp_header(\"www-authenticate\", ~s(Basic realm=\"Soundboard\"))\n    |> put_resp_content_type(\"text/plain\")\n    |> send_resp(401, \"Unauthorized\")\n    |> halt()\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/plugs/role_check.ex",
    "content": "defmodule SoundboardWeb.Plugs.RoleCheck do\n  @moduledoc false\n  require Logger\n  import Plug.Conn\n  import Phoenix.Controller\n\n  alias Soundboard.Discord.RoleChecker\n\n  def init(opts), do: opts\n\n  def call(conn, _opts) do\n    cond do\n      is_nil(conn.assigns[:current_user]) -> conn\n      not RoleChecker.feature_enabled?() -> conn\n      true -> check_role(conn)\n    end\n  end\n\n  defp check_role(conn) do\n    roles_verified_at = get_session(conn, :roles_verified_at)\n    recheck_interval = Application.get_env(:soundboard, :role_recheck_interval_seconds, 900)\n\n    if fresh?(roles_verified_at, recheck_interval) do\n      conn\n    else\n      discord_id = conn.assigns.current_user.discord_id\n\n      if RoleChecker.authorized?(discord_id) do\n        put_session(conn, :roles_verified_at, System.system_time(:second))\n      else\n        Logger.warning(\"Role check failed for Discord user #{discord_id}, clearing session\")\n\n        conn\n        |> clear_session()\n        |> put_flash(:error, \"Error signing in\")\n        |> redirect(to: \"/\")\n        |> halt()\n      end\n    end\n  end\n\n  defp fresh?(nil, _interval), do: false\n\n  defp fresh?(verified_at, interval) when is_integer(verified_at) do\n    System.system_time(:second) - verified_at < interval\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/presence.ex",
    "content": "defmodule SoundboardWeb.Presence do\n  @moduledoc \"\"\"\n  The Presence module.\n  \"\"\"\n  use Phoenix.Presence,\n    otp_app: :soundboard,\n    pubsub_server: Soundboard.PubSub\nend\n"
  },
  {
    "path": "lib/soundboard_web/presence_handler.ex",
    "content": "defmodule SoundboardWeb.PresenceHandler do\n  @moduledoc \"\"\"\n  Handles presence tracking for the Soundboard app.\n  \"\"\"\n  use GenServer\n\n  import Phoenix.LiveView, only: [connected?: 1]\n  alias SoundboardWeb.Presence\n\n  @presence_topic \"soundboard:presence\"\n\n  @colors [\n    \"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200\",\n    \"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200\",\n    \"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200\",\n    \"bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200\",\n    \"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200\",\n    \"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200\",\n    \"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200\",\n    \"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200\",\n    \"bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200\",\n    \"bg-slate-100 text-slate-800 dark:bg-slate-900 dark:text-slate-200\",\n    \"bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200\",\n    \"bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200\",\n    \"bg-stone-100 text-stone-800 dark:bg-stone-900 dark:text-stone-200\",\n    \"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200\"\n  ]\n\n  @colors_key :user_colors\n\n  def start_link(_opts) do\n    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)\n  end\n\n  @impl true\n  def init(:ok) do\n    :persistent_term.put(@colors_key, %{})\n    {:ok, %{}}\n  end\n\n  def track_presence(socket, user) do\n    if connected?(socket) do\n      username = if user, do: user.username, else: \"Anonymous #{socket.id |> String.slice(0..5)}\"\n      color = get_random_unique_color(username)\n\n      Presence.track(self(), @presence_topic, socket.id, %{\n        online_at: System.system_time(:second),\n        user: %{\n          username: username,\n          avatar: if(user, do: user.avatar, else: nil),\n          color: color\n        }\n      })\n    end\n  end\n\n  @spec get_user_color(String.t()) :: String.t()\n  def get_user_color(username) do\n    colors = :persistent_term.get(@colors_key, %{})\n    Map.get(colors, username) || get_random_unique_color(username)\n  end\n\n  defp get_random_unique_color(username) do\n    colors = :persistent_term.get(@colors_key, %{})\n    used_colors = Map.values(colors)\n\n    available_colors = Enum.reject(@colors, &(&1 in used_colors))\n\n    color =\n      if Enum.empty?(available_colors) do\n        # If all colors are used, pick a random one\n        Enum.random(@colors)\n      else\n        Enum.random(available_colors)\n      end\n\n    :persistent_term.put(@colors_key, Map.put(colors, username, color))\n\n    color\n  end\n\n  def get_presence_count do\n    @presence_topic\n    |> Presence.list()\n    |> count_active_presences()\n  end\n\n  def handle_presence_diff(%{joins: joins, leaves: leaves}, current_count) do\n    now = System.system_time(:second)\n\n    active_joins = count_active_presences(joins, now)\n    active_leaves = count_active_presences(leaves, now)\n\n    max(current_count + (active_joins - active_leaves), 0)\n  end\n\n  defp count_active_presences(presences) do\n    now = System.system_time(:second)\n    count_active_presences(presences, now)\n  end\n\n  defp count_active_presences(presences, now) do\n    Enum.count(presences, fn {_id, presence} ->\n      metas = presence.metas || []\n\n      Enum.any?(metas, fn %{online_at: online_at} ->\n        now - online_at < 60\n      end)\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/router.ex",
    "content": "defmodule SoundboardWeb.Router do\n  use SoundboardWeb, :router\n\n  pipeline :browser do\n    plug :accepts, [\"html\"]\n    plug :fetch_session\n    plug :fetch_live_flash\n    plug :put_root_layout, html: {SoundboardWeb.Layouts, :root}\n    plug :protect_from_forgery\n    plug :put_secure_browser_headers\n  end\n\n  pipeline :require_browser_basic_auth do\n    plug SoundboardWeb.Plugs.BasicAuth\n  end\n\n  pipeline :require_role_check do\n    plug SoundboardWeb.Plugs.RoleCheck\n  end\n\n  pipeline :auth do\n    plug :fetch_session\n    plug :fetch_current_user\n  end\n\n  pipeline :auth_browser do\n    plug :accepts, [\"html\"]\n    plug :fetch_session\n    plug :protect_from_forgery\n    plug :put_secure_browser_headers\n    plug :put_session_opts\n  end\n\n  pipeline :api do\n    plug :accepts, [\"json\"]\n    plug SoundboardWeb.Plugs.APIAuth\n  end\n\n  # Discord OAuth routes - must come before protected routes\n  scope \"/auth\", SoundboardWeb do\n    pipe_through [:browser]\n\n    get \"/:provider\", AuthController, :request\n    get \"/:provider/callback\", AuthController, :callback\n    delete \"/logout\", AuthController, :logout\n  end\n\n  # Protected routes\n  scope \"/\", SoundboardWeb do\n    pipe_through [\n      :browser,\n      :auth,\n      :ensure_authenticated_user,\n      :require_role_check,\n      :require_browser_basic_auth\n    ]\n\n    live \"/\", SoundboardLive\n    live \"/stats\", StatsLive\n    live \"/favorites\", FavoritesLive\n    live \"/settings\", SettingsLive\n  end\n\n  scope \"/uploads\" do\n    pipe_through [\n      :browser,\n      :auth,\n      :ensure_authenticated_user,\n      :require_role_check,\n      :require_browser_basic_auth\n    ]\n\n    get \"/*path\", SoundboardWeb.UploadController, :show\n  end\n\n  if Mix.env() == :test do\n    scope \"/debug\", SoundboardWeb do\n      pipe_through [:browser]\n\n      get \"/session\", AuthController, :debug_session\n    end\n  end\n\n  # Add this new scope for API routes before your other scopes\n  scope \"/api\", SoundboardWeb.API do\n    pipe_through :api\n\n    get \"/sounds\", SoundController, :index\n    post \"/sounds\", SoundController, :create\n    post \"/sounds/:id/play\", SoundController, :play\n    post \"/sounds/stop\", SoundController, :stop\n  end\n\n  def fetch_current_user(conn, _) do\n    user_id = get_session(conn, :user_id)\n\n    if user_id do\n      case Soundboard.Accounts.get_user(user_id) do\n        nil ->\n          conn\n          |> clear_session()\n          |> assign(:current_user, nil)\n\n        user ->\n          assign(conn, :current_user, user)\n      end\n    else\n      assign(conn, :current_user, nil)\n    end\n  end\n\n  def ensure_authenticated_user(conn, _opts) do\n    if conn.assigns[:current_user] do\n      conn\n    else\n      conn\n      |> put_session(:return_to, conn.request_path)\n      |> redirect(to: \"/auth/discord\")\n      |> halt()\n    end\n  end\n\n  defp put_session_opts(conn, _opts) do\n    conn\n    |> put_resp_cookie(\"_soundboard_key\", \"\",\n      max_age: 86_400 * 30,\n      same_site: \"Lax\",\n      secure: Application.get_env(:soundboard, :env) == :prod,\n      http_only: true,\n      path: \"/\"\n    )\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/sound_helpers.ex",
    "content": "defmodule SoundboardWeb.SoundHelpers do\n  @moduledoc \"\"\"\n  Shared helpers for formatting sound metadata for UI rendering.\n  \"\"\"\n\n  def display_name(nil), do: \"\"\n\n  def display_name(filename) when is_binary(filename) do\n    filename\n    |> Path.basename()\n    |> Path.rootname()\n  end\n\n  def display_name(other), do: to_string(other)\n\n  def slugify(name) do\n    name\n    |> display_name()\n    |> String.downcase()\n    |> String.replace(~r/[^a-z0-9]+/, \"-\", global: true)\n    |> String.trim(\"-\")\n    |> ensure_slug()\n  end\n\n  defp ensure_slug(\"\"), do: \"sound\"\n  defp ensure_slug(slug), do: slug\nend\n"
  },
  {
    "path": "lib/soundboard_web/soundboard/sound_filter.ex",
    "content": "defmodule SoundboardWeb.Soundboard.SoundFilter do\n  @moduledoc \"\"\"\n  Filters sounds based on the selected tags and search query.\n  \"\"\"\n\n  def filter_sounds(sounds, query, selected_tags) do\n    sounds\n    |> filter_by_tags(selected_tags)\n    |> filter_by_search(query)\n  end\n\n  defp filter_by_tags(sounds, []), do: sounds\n\n  defp filter_by_tags(sounds, selected_tags) do\n    selected_tag_ids = MapSet.new(selected_tags, & &1.id)\n\n    Enum.filter(sounds, fn sound ->\n      sound_tag_ids = MapSet.new(sound.tags, & &1.id)\n      MapSet.subset?(selected_tag_ids, sound_tag_ids)\n    end)\n  end\n\n  defp filter_by_search(sounds, \"\"), do: sounds\n\n  defp filter_by_search(sounds, query) do\n    query = String.downcase(query)\n\n    Enum.filter(sounds, fn sound ->\n      filename_matches = String.downcase(sound.filename) =~ query\n\n      tag_matches =\n        Enum.any?(sound.tags, fn tag ->\n          String.downcase(tag.name) =~ query\n        end)\n\n      filename_matches || tag_matches\n    end)\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web/telemetry.ex",
    "content": "defmodule SoundboardWeb.Telemetry do\n  use Supervisor\n  import Telemetry.Metrics\n\n  def start_link(arg) do\n    Supervisor.start_link(__MODULE__, arg, name: __MODULE__)\n  end\n\n  @impl true\n  def init(_arg) do\n    children = [\n      # Telemetry poller will execute the given period measurements\n      # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics\n      {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}\n      # Add reporters as children of your supervision tree.\n      # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}\n    ]\n\n    Supervisor.init(children, strategy: :one_for_one)\n  end\n\n  def metrics do\n    [\n      # Phoenix Metrics\n      summary(\"phoenix.endpoint.start.system_time\",\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.endpoint.stop.duration\",\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.router_dispatch.start.system_time\",\n        tags: [:route],\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.router_dispatch.exception.duration\",\n        tags: [:route],\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.router_dispatch.stop.duration\",\n        tags: [:route],\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.socket_connected.duration\",\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.channel_joined.duration\",\n        unit: {:native, :millisecond}\n      ),\n      summary(\"phoenix.channel_handled_in.duration\",\n        tags: [:event],\n        unit: {:native, :millisecond}\n      ),\n\n      # Database Metrics\n      summary(\"soundboard.repo.query.total_time\",\n        unit: {:native, :millisecond},\n        description: \"The sum of the other measurements\"\n      ),\n      summary(\"soundboard.repo.query.decode_time\",\n        unit: {:native, :millisecond},\n        description: \"The time spent decoding the data received from the database\"\n      ),\n      summary(\"soundboard.repo.query.query_time\",\n        unit: {:native, :millisecond},\n        description: \"The time spent executing the query\"\n      ),\n      summary(\"soundboard.repo.query.queue_time\",\n        unit: {:native, :millisecond},\n        description: \"The time spent waiting for a database connection\"\n      ),\n      summary(\"soundboard.repo.query.idle_time\",\n        unit: {:native, :millisecond},\n        description:\n          \"The time the connection spent waiting before being checked out for the query\"\n      ),\n\n      # VM Metrics\n      summary(\"vm.memory.total\", unit: {:byte, :kilobyte}),\n      summary(\"vm.total_run_queue_lengths.total\"),\n      summary(\"vm.total_run_queue_lengths.cpu\"),\n      summary(\"vm.total_run_queue_lengths.io\")\n    ]\n  end\n\n  defp periodic_measurements do\n    [\n      # A module, function and arguments to be invoked periodically.\n      # This function must call :telemetry.execute/3 and a metric must be added above.\n      # {SoundboardWeb, :count_users, []}\n    ]\n  end\nend\n"
  },
  {
    "path": "lib/soundboard_web.ex",
    "content": "defmodule SoundboardWeb do\n  @moduledoc \"\"\"\n  The entrypoint for defining your web interface, such\n  as controllers, components, channels, and so on.\n\n  This can be used in your application as:\n\n      use SoundboardWeb, :controller\n      use SoundboardWeb, :html\n\n  The definitions below will be executed for every controller,\n  component, etc, so keep them short and clean, focused\n  on imports, uses and aliases.\n\n  Do NOT define functions inside the quoted expressions\n  below. Instead, define additional modules and import\n  those modules here.\n  \"\"\"\n\n  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)\n\n  def router do\n    quote do\n      use Phoenix.Router, helpers: false\n\n      # Import common connection and controller functions to use in pipelines\n      import Plug.Conn\n      import Phoenix.Controller\n      import Phoenix.LiveView.Router\n    end\n  end\n\n  def channel do\n    quote do\n      use Phoenix.Channel\n    end\n  end\n\n  def controller do\n    quote do\n      use Phoenix.Controller,\n        formats: [:html, :json],\n        layouts: [html: SoundboardWeb.Layouts]\n\n      use Gettext, backend: SoundboardWeb.Gettext\n\n      import Plug.Conn\n\n      unquote(verified_routes())\n    end\n  end\n\n  def live_view do\n    quote do\n      use Phoenix.LiveView,\n        layout: {SoundboardWeb.Layouts, :app}\n\n      unquote(html_helpers())\n    end\n  end\n\n  def live_component do\n    quote do\n      use Phoenix.LiveComponent\n\n      unquote(html_helpers())\n    end\n  end\n\n  def html do\n    quote do\n      use Phoenix.Component\n\n      # Import convenience functions from controllers\n      import Phoenix.Controller,\n        only: [get_csrf_token: 0, view_module: 1, view_template: 1]\n\n      # Include general helpers for rendering HTML\n      unquote(html_helpers())\n    end\n  end\n\n  defp html_helpers do\n    quote do\n      # Translation\n      use Gettext, backend: SoundboardWeb.Gettext\n\n      # HTML escaping functionality\n      import Phoenix.HTML\n      # Core UI components\n      import SoundboardWeb.CoreComponents\n      import SoundboardWeb.SoundHelpers\n\n      # Shortcut for generating JS commands\n      alias Phoenix.LiveView.JS\n\n      # Routes generation with the ~p sigil\n      unquote(verified_routes())\n    end\n  end\n\n  def verified_routes do\n    quote do\n      use Phoenix.VerifiedRoutes,\n        endpoint: SoundboardWeb.Endpoint,\n        router: SoundboardWeb.Router,\n        statics: SoundboardWeb.static_paths()\n    end\n  end\n\n  @doc \"\"\"\n  When used, dispatch to the appropriate controller/live_view/etc.\n  \"\"\"\n  defmacro __using__(which) when is_atom(which) do\n    apply(__MODULE__, which, [])\n  end\nend\n"
  },
  {
    "path": "mix.exs",
    "content": "defmodule Soundboard.MixProject do\n  use Mix.Project\n\n  @moduledoc \"\"\"\n  Mix project configuration for Soundbored, the self-hosted Discord soundboard\n  that powers audio playback, live dashboards, and API integrations described in\n  the repository README.\n  \"\"\"\n\n  def project do\n    [\n      app: :soundboard,\n      version: \"1.7.0\",\n      elixir: \"~> 1.19\",\n      elixirc_paths: elixirc_paths(Mix.env()),\n      start_permanent: Mix.env() == :prod,\n      listeners: [Phoenix.CodeReloader],\n      aliases: aliases(),\n      deps: deps(),\n      test_coverage: [\n        tool: ExCoveralls,\n        ignore_modules: [\n          SoundboardWeb.CoreComponents,\n          SoundboardWeb.Components.FlashComponent,\n          SoundboardWeb.Components.Layouts,\n          SoundboardWeb.Router,\n          SoundboardWeb.Telemetry,\n          SoundboardWeb.Endpoint,\n          SoundboardWeb.Gettext,\n          # Controllers and views with no meaningful coverage needs\n          SoundboardWeb.ErrorHTML,\n          SoundboardWeb.ErrorJSON,\n          SoundboardWeb.PageController,\n          SoundboardWeb.PageHTML,\n          SoundboardWeb.UploadController,\n          # Live views that might need separate testing strategy\n          SoundboardWeb.PresenceLive,\n          SoundboardWeb.Presence,\n          # Repo and application modules\n          Soundboard.Repo,\n          # Test support files\n          SoundboardWeb.ConnCase,\n          Soundboard.DataCase,\n          Soundboard.TestHelpers\n        ]\n      ]\n    ]\n  end\n\n  # Configuration for the OTP application.\n  #\n  # Type `mix help compile.app` for more information.\n  def application do\n    apps = [:logger, :runtime_tools]\n\n    [\n      mod: {Soundboard.Application, []},\n      extra_applications: apps,\n      included_applications: []\n    ]\n  end\n\n  # Specifies which paths to compile per environment.\n  defp elixirc_paths(:test), do: [\"lib\", \"test/support\"]\n  defp elixirc_paths(_), do: [\"lib\"]\n\n  # Specifies your project dependencies.\n  #\n  # Type `mix help deps` for examples and options.\n  defp deps do\n    [\n      {:phoenix, \"~> 1.8.0\"},\n      {:phoenix_ecto, \"~> 4.6.5\"},\n      {:ecto_sql, \"~> 3.10\"},\n      {:phoenix_html, \"~> 4.1\"},\n      {:phoenix_live_reload, \"~> 1.6\", only: :dev},\n      {:phoenix_live_view, \"~> 1.1.0\"},\n      {:floki, \">= 0.30.0\", only: :test},\n      {:lazy_html, \">= 0.1.0\", only: :test},\n      {:phoenix_live_dashboard, \"~> 0.8.7\"},\n      {:esbuild, \"~> 0.10\", runtime: Mix.env() == :dev},\n      {:tailwind, \"~> 0.3\", runtime: Mix.env() == :dev},\n      {:heroicons, github: \"tailwindlabs/heroicons\", tag: \"v2.1.1\", app: false, compile: false},\n      {:telemetry_metrics, \"~> 1.1\"},\n      {:telemetry_poller, \"~> 1.3\"},\n      {:gettext, \"~> 1.0\"},\n      {:jason, \"~> 1.2\"},\n      {:bandit, \"~> 1.8\"},\n      {:eda, \"~> 0.1.3\"},\n      {:rustler, \"~> 0.35\", runtime: false},\n      {:ecto_sqlite3, \"~> 0.22\"},\n      {:ueberauth, \"~> 0.10.5\"},\n      {:ueberauth_discord, \"~> 0.6\"},\n      {:mock, \"~> 0.3.9\", only: :test},\n      {:dotenvy, \"~> 1.0.0\", runtime: false},\n      {:excoveralls, \"~> 0.18.5\", only: :test},\n      {:credo, \"~> 1.7\", only: [:dev, :test], runtime: false},\n      {:ex_doc, \"~> 0.34\", only: :dev, runtime: false},\n      {:ex_dna, \"~> 1.1\", only: [:dev, :test], runtime: false},\n      {:ex_slop, \"~> 0.2\", only: [:dev, :test], runtime: false}\n    ]\n  end\n\n  # Aliases are shortcuts or tasks specific to the current project.\n  # For example, to install project dependencies and perform other setup tasks, run:\n  #\n  #     $ mix setup\n  #\n  # See the documentation for `Mix` for more info on aliases.\n  defp aliases do\n    [\n      setup: [\"deps.get\", \"ecto.setup\", \"assets.setup\", \"assets.build\"],\n      \"ecto.setup\": [\"ecto.create\", \"ecto.migrate\"],\n      \"ecto.reset\": [\"ecto.drop\", \"ecto.setup\"],\n      test: [\"ecto.create --quiet\", \"ecto.migrate --quiet\", \"test\"],\n      precommit: [\n        \"compile --warnings-as-errors\",\n        \"deps.unlock --unused\",\n        \"format\",\n        \"credo --strict\",\n        \"cmd env MIX_ENV=test mix test\",\n        \"ex_dna\"\n      ],\n      \"assets.setup\": [\"tailwind.install --if-missing\", \"esbuild.install --if-missing\"],\n      \"assets.build\": [\"tailwind soundboard\", \"esbuild soundboard\"],\n      \"assets.deploy\": [\n        \"tailwind soundboard --minify\",\n        \"esbuild soundboard --minify\",\n        \"phx.digest\"\n      ]\n    ]\n  end\n\n  def cli do\n    [\n      preferred_envs: [\n        coveralls: :test,\n        \"coveralls.detail\": :test,\n        \"coveralls.post\": :test,\n        \"coveralls.html\": :test,\n        \"coveralls.json\": :test,\n        \"coveralls.github\": :test\n      ]\n    ]\n  end\nend\n"
  },
  {
    "path": "priv/gettext/en/LC_MESSAGES/errors.po",
    "content": "## `msgid`s in this file come from POT (.pot) files.\n##\n## Do not add, change, or remove `msgid`s manually here as\n## they're tied to the ones in the corresponding POT file\n## (with the same domain).\n##\n## Use `mix gettext.extract --merge` or `mix gettext.merge`\n## to merge POT files into PO files.\nmsgid \"\"\nmsgstr \"\"\n\"Language: en\\n\"\n\n## From Ecto.Changeset.cast/4\nmsgid \"can't be blank\"\nmsgstr \"\"\n\n## From Ecto.Changeset.unique_constraint/3\nmsgid \"has already been taken\"\nmsgstr \"\"\n\n## From Ecto.Changeset.put_change/3\nmsgid \"is invalid\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_acceptance/3\nmsgid \"must be accepted\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_format/3\nmsgid \"has invalid format\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_subset/3\nmsgid \"has an invalid entry\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_exclusion/3\nmsgid \"is reserved\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_confirmation/3\nmsgid \"does not match confirmation\"\nmsgstr \"\"\n\n## From Ecto.Changeset.no_assoc_constraint/3\nmsgid \"is still associated with this entry\"\nmsgstr \"\"\n\nmsgid \"are still associated with this entry\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_length/3\nmsgid \"should have %{count} item(s)\"\nmsgid_plural \"should have %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be %{count} character(s)\"\nmsgid_plural \"should be %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be %{count} byte(s)\"\nmsgid_plural \"should be %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should have at least %{count} item(s)\"\nmsgid_plural \"should have at least %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at least %{count} character(s)\"\nmsgid_plural \"should be at least %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at least %{count} byte(s)\"\nmsgid_plural \"should be at least %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should have at most %{count} item(s)\"\nmsgid_plural \"should have at most %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at most %{count} character(s)\"\nmsgid_plural \"should be at most %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at most %{count} byte(s)\"\nmsgid_plural \"should be at most %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\n## From Ecto.Changeset.validate_number/3\nmsgid \"must be less than %{number}\"\nmsgstr \"\"\n\nmsgid \"must be greater than %{number}\"\nmsgstr \"\"\n\nmsgid \"must be less than or equal to %{number}\"\nmsgstr \"\"\n\nmsgid \"must be greater than or equal to %{number}\"\nmsgstr \"\"\n\nmsgid \"must be equal to %{number}\"\nmsgstr \"\"\n"
  },
  {
    "path": "priv/gettext/errors.pot",
    "content": "## This is a PO Template file.\n##\n## `msgid`s here are often extracted from source code.\n## Add new translations manually only if they're dynamic\n## translations that can't be statically extracted.\n##\n## Run `mix gettext.extract` to bring this file up to\n## date. Leave `msgstr`s empty as changing them here has no\n## effect: edit them in PO (`.po`) files instead.\n## From Ecto.Changeset.cast/4\nmsgid \"can't be blank\"\nmsgstr \"\"\n\n## From Ecto.Changeset.unique_constraint/3\nmsgid \"has already been taken\"\nmsgstr \"\"\n\n## From Ecto.Changeset.put_change/3\nmsgid \"is invalid\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_acceptance/3\nmsgid \"must be accepted\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_format/3\nmsgid \"has invalid format\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_subset/3\nmsgid \"has an invalid entry\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_exclusion/3\nmsgid \"is reserved\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_confirmation/3\nmsgid \"does not match confirmation\"\nmsgstr \"\"\n\n## From Ecto.Changeset.no_assoc_constraint/3\nmsgid \"is still associated with this entry\"\nmsgstr \"\"\n\nmsgid \"are still associated with this entry\"\nmsgstr \"\"\n\n## From Ecto.Changeset.validate_length/3\nmsgid \"should have %{count} item(s)\"\nmsgid_plural \"should have %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be %{count} character(s)\"\nmsgid_plural \"should be %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be %{count} byte(s)\"\nmsgid_plural \"should be %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should have at least %{count} item(s)\"\nmsgid_plural \"should have at least %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at least %{count} character(s)\"\nmsgid_plural \"should be at least %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at least %{count} byte(s)\"\nmsgid_plural \"should be at least %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should have at most %{count} item(s)\"\nmsgid_plural \"should have at most %{count} item(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at most %{count} character(s)\"\nmsgid_plural \"should be at most %{count} character(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\nmsgid \"should be at most %{count} byte(s)\"\nmsgid_plural \"should be at most %{count} byte(s)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n\n## From Ecto.Changeset.validate_number/3\nmsgid \"must be less than %{number}\"\nmsgstr \"\"\n\nmsgid \"must be greater than %{number}\"\nmsgstr \"\"\n\nmsgid \"must be less than or equal to %{number}\"\nmsgstr \"\"\n\nmsgid \"must be greater than or equal to %{number}\"\nmsgstr \"\"\n\nmsgid \"must be equal to %{number}\"\nmsgstr \"\"\n"
  },
  {
    "path": "priv/repo/migrations/.formatter.exs",
    "content": "[\n  import_deps: [:ecto_sql],\n  inputs: [\"*.exs\"]\n]\n"
  },
  {
    "path": "priv/repo/migrations/20250101213201_create_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateSounds do\n  use Ecto.Migration\n\n  def change do\n    create table(:sounds) do\n      add :filename, :string, null: false\n      add :tags, {:array, :string}, default: []\n      add :description, :text\n\n      timestamps()\n    end\n\n    create unique_index(:sounds, [:filename])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250101213717_create_tags.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateTags do\n  use Ecto.Migration\n\n  def change do\n    create table(:tags) do\n      add :name, :string, null: false\n\n      timestamps()\n    end\n\n    create unique_index(:tags, [:name])\n\n    create table(:sound_tags) do\n      add :sound_id, references(:sounds, on_delete: :delete_all), null: false\n      add :tag_id, references(:tags, on_delete: :delete_all), null: false\n\n      timestamps()\n    end\n\n    create unique_index(:sound_tags, [:sound_id, :tag_id])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250101231744_create_users.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateUsers do\n  use Ecto.Migration\n\n  def change do\n    create table(:users) do\n      add :discord_id, :string, null: false\n      add :username, :string, null: false\n      add :avatar, :string\n\n      timestamps()\n    end\n\n    create unique_index(:users, [:discord_id])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212120_create_plays.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreatePlays do\n  use Ecto.Migration\n\n  def change do\n    create table(:plays) do\n      add :sound_name, :string, null: false\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n\n      timestamps()\n    end\n\n    create index(:plays, [:user_id])\n    create index(:plays, [:sound_name])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212121_create_favorites.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateFavorites do\n  use Ecto.Migration\n\n  def change do\n    create table(:favorites) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :filename, :string, null: false\n\n      timestamps()\n    end\n\n    create unique_index(:favorites, [:user_id, :filename])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212122_add_user_id_to_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddUserIdToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds) do\n      add :user_id, references(:users, on_delete: :nilify_all)\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212123_change_favorites_filename_to_sound_id.exs",
    "content": "defmodule Soundboard.Repo.Migrations.ChangeFavoritesFilenameToSoundId do\n  use Ecto.Migration\n\n  def up do\n    # First add the new column while keeping the old one\n    alter table(:favorites) do\n      add :sound_id, :integer\n    end\n\n    # Copy data from filename to sound_id by joining with sounds table\n    execute \"\"\"\n    UPDATE favorites SET sound_id = (\n      SELECT id FROM sounds WHERE sounds.filename = favorites.filename\n    )\n    \"\"\"\n\n    # Create a new table with the desired schema\n    create table(:favorites_new) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :sound_id, :integer, null: false\n      timestamps()\n    end\n\n    # Copy data to the new table\n    execute \"\"\"\n    INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)\n    SELECT user_id, sound_id, inserted_at, updated_at FROM favorites\n    WHERE sound_id IS NOT NULL\n    \"\"\"\n\n    # Drop the old table and rename the new one\n    drop table(:favorites)\n    execute \"ALTER TABLE favorites_new RENAME TO favorites\"\n\n    # Create the new index\n    create unique_index(:favorites, [:user_id, :sound_id])\n  end\n\n  def down do\n    # Create a new table with the old schema\n    create table(:favorites_new) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :filename, :string, null: false\n      timestamps()\n    end\n\n    # Copy data back by joining with sounds table\n    execute \"\"\"\n    INSERT INTO favorites_new (user_id, filename, inserted_at, updated_at)\n    SELECT f.user_id, s.filename, f.inserted_at, f.updated_at\n    FROM favorites f\n    JOIN sounds s ON s.id = f.sound_id\n    \"\"\"\n\n    # Drop the old table and rename the new one\n    drop table(:favorites)\n    execute \"ALTER TABLE favorites_new RENAME TO favorites\"\n\n    # Recreate the old index\n    create unique_index(:favorites, [:user_id, :filename],\n             name: :favorites_user_id_filename_index\n           )\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212124_add_index_to_plays.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddIndexToPlays do\n  use Ecto.Migration\n\n  def change do\n    create index(:plays, [:inserted_at])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212125_add_join_leave_flags_to_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddJoinLeaveFlagsToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds) do\n      add :is_join_sound, :boolean, default: false\n      add :is_leave_sound, :boolean, default: false\n    end\n\n    # Ensure only one join and one leave sound per user\n    create unique_index(:sounds, [:user_id, :is_join_sound],\n             name: :user_join_sound_index,\n             # Use proper SQL boolean\n             where: \"is_join_sound = TRUE\"\n           )\n\n    create unique_index(:sounds, [:user_id, :is_leave_sound],\n             name: :user_leave_sound_index,\n             # Use proper SQL boolean\n             where: \"is_leave_sound = TRUE\"\n           )\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250102212126_add_url_to_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddUrlToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds) do\n      add :url, :string\n      add :source_type, :string, default: \"local\", null: false\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250218214831_create_user_sound_settings.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateUserSoundSettings do\n  use Ecto.Migration\n\n  def change do\n    create table(:user_sound_settings) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :sound_id, references(:sounds, on_delete: :delete_all), null: false\n      add :is_join_sound, :boolean, default: false\n      add :is_leave_sound, :boolean, default: false\n\n      timestamps()\n    end\n\n    create index(:user_sound_settings, [:user_id])\n    create index(:user_sound_settings, [:sound_id])\n\n    create unique_index(:user_sound_settings, [:user_id, :is_join_sound],\n             where: \"is_join_sound = 1\",\n             name: :user_sound_settings_join_sound_index\n           )\n\n    create unique_index(:user_sound_settings, [:user_id, :is_leave_sound],\n             where: \"is_leave_sound = 1\",\n             name: :user_sound_settings_leave_sound_index\n           )\n\n    execute \"\"\"\n            INSERT INTO user_sound_settings (user_id, sound_id, is_join_sound, is_leave_sound, inserted_at, updated_at)\n            SELECT user_id, id, COALESCE(is_join_sound, 0), COALESCE(is_leave_sound, 0), datetime('now'), datetime('now')\n            FROM sounds\n            WHERE is_join_sound = 1 OR is_leave_sound = 1;\n            \"\"\",\n            \"\"\"\n            DELETE FROM user_sound_settings;\n            \"\"\"\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250218214832_remove_join_leave_flags_from_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.RemoveJoinLeaveFlagsFromSounds do\n  use Ecto.Migration\n\n  def change do\n    drop_if_exists index(:sounds, [:is_join_sound], name: :user_join_sound_index)\n    drop_if_exists index(:sounds, [:is_leave_sound], name: :user_leave_sound_index)\n\n    alter table(:sounds) do\n      remove :is_join_sound\n      remove :is_leave_sound\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250218220000_create_api_tokens.exs",
    "content": "defmodule Soundboard.Repo.Migrations.CreateApiTokens do\n  use Ecto.Migration\n\n  def change do\n    create table(:api_tokens) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :token_hash, :string, null: false\n      add :label, :string\n      add :revoked_at, :naive_datetime\n      add :last_used_at, :naive_datetime\n\n      timestamps()\n    end\n\n    create unique_index(:api_tokens, [:token_hash])\n    create index(:api_tokens, [:user_id])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250218223000_add_token_plain_to_api_tokens.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddTokenPlainToApiTokens do\n  use Ecto.Migration\n\n  def change do\n    alter table(:api_tokens) do\n      add :token, :string, null: false, default: \"\"\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20250310120000_add_volume_to_sounds.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddVolumeToSounds do\n  use Ecto.Migration\n\n  def change do\n    alter table(:sounds) do\n      add :volume, :float, default: 1.0, null: false\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20260306150000_add_sound_id_to_plays.exs",
    "content": "defmodule Soundboard.Repo.Migrations.AddSoundIdToPlays do\n  use Ecto.Migration\n\n  def up do\n    alter table(:plays) do\n      add :sound_id, references(:sounds, on_delete: :nilify_all)\n    end\n\n    execute(\"\"\"\n    UPDATE plays\n    SET sound_id = (\n      SELECT sounds.id\n      FROM sounds\n      WHERE sounds.filename = plays.sound_name\n    )\n    WHERE sound_id IS NULL\n    \"\"\")\n\n    create index(:plays, [:sound_id])\n  end\n\n  def down do\n    drop index(:plays, [:sound_id])\n\n    alter table(:plays) do\n      remove :sound_id\n    end\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20260306151000_finalize_favorites_and_sound_tags_migrations.exs",
    "content": "defmodule Soundboard.Repo.Migrations.FinalizeFavoritesAndSoundTagsMigrations do\n  use Ecto.Migration\n\n  def up do\n    create table(:favorites_new) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :sound_id, references(:sounds, on_delete: :delete_all), null: false\n      timestamps()\n    end\n\n    execute(\"\"\"\n    INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)\n    SELECT f.user_id, f.sound_id, f.inserted_at, f.updated_at\n    FROM favorites f\n    JOIN sounds s ON s.id = f.sound_id\n    \"\"\")\n\n    drop table(:favorites)\n    execute(\"ALTER TABLE favorites_new RENAME TO favorites\")\n\n    create unique_index(:favorites, [:user_id, :sound_id])\n    create index(:favorites, [:sound_id])\n\n    execute(\"\"\"\n    INSERT INTO tags (name, inserted_at, updated_at)\n    SELECT DISTINCT lower(trim(j.value)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP\n    FROM sounds s\n    JOIN json_each(s.tags) AS j\n    WHERE json_valid(s.tags)\n      AND trim(j.value) != ''\n      AND NOT EXISTS (\n        SELECT 1 FROM tags existing WHERE existing.name = lower(trim(j.value))\n      )\n    \"\"\")\n\n    execute(\"\"\"\n    INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at)\n    SELECT s.id, t.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP\n    FROM sounds s\n    JOIN json_each(s.tags) AS j\n    JOIN tags t ON t.name = lower(trim(j.value))\n    WHERE json_valid(s.tags)\n      AND trim(j.value) != ''\n      AND NOT EXISTS (\n        SELECT 1\n        FROM sound_tags st\n        WHERE st.sound_id = s.id AND st.tag_id = t.id\n      )\n    \"\"\")\n\n    alter table(:sounds) do\n      remove :tags\n    end\n  end\n\n  def down do\n    alter table(:sounds) do\n      add :tags, {:array, :string}, default: []\n    end\n\n    execute(\"\"\"\n    UPDATE sounds\n    SET tags = COALESCE((\n      SELECT json_group_array(name)\n      FROM (\n        SELECT t.name AS name\n        FROM sound_tags st\n        JOIN tags t ON t.id = st.tag_id\n        WHERE st.sound_id = sounds.id\n        ORDER BY t.name\n      )\n    ), '[]')\n    \"\"\")\n\n    create table(:favorites_new) do\n      add :user_id, references(:users, on_delete: :delete_all), null: false\n      add :sound_id, :integer, null: false\n      timestamps()\n    end\n\n    execute(\"\"\"\n    INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)\n    SELECT user_id, sound_id, inserted_at, updated_at\n    FROM favorites\n    \"\"\")\n\n    drop table(:favorites)\n    execute(\"ALTER TABLE favorites_new RENAME TO favorites\")\n\n    create unique_index(:favorites, [:user_id, :sound_id])\n  end\nend\n"
  },
  {
    "path": "priv/repo/migrations/20260307211000_rename_sound_name_to_played_filename_in_plays.exs",
    "content": "defmodule Soundboard.Repo.Migrations.RenameSoundNameToPlayedFilenameInPlays do\n  use Ecto.Migration\n\n  def up do\n    drop_if_exists index(:plays, [:sound_name])\n\n    rename table(:plays), :sound_name, to: :played_filename\n\n    execute(\"\"\"\n    UPDATE plays\n    SET sound_id = (\n      SELECT sounds.id\n      FROM sounds\n      WHERE sounds.filename = plays.played_filename\n    )\n    WHERE sound_id IS NULL\n    \"\"\")\n\n    create index(:plays, [:played_filename])\n  end\n\n  def down do\n    drop_if_exists index(:plays, [:played_filename])\n\n    rename table(:plays), :played_filename, to: :sound_name\n\n    create index(:plays, [:sound_name])\n  end\nend\n"
  },
  {
    "path": "priv/repo/seeds.exs",
    "content": "# Soundbored has no default seed data. Add only idempotent local bootstrap data here when needed.\n"
  },
  {
    "path": "priv/static/manifest.json",
    "content": "{\n  \"name\": \"SoundBored\",\n  \"short_name\": \"SoundBored\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"orientation\": \"portrait\",\n  \"background_color\": \"#1f2937\",\n  \"theme_color\": \"#1f2937\",\n  \"scope\": \"/\",\n  \"icons\": [\n    {\n      \"src\": \"/images/icon-192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/images/icon-512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n} "
  },
  {
    "path": "priv/static/robots.txt",
    "content": "# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file\n#\n# To ban all spiders from the entire site uncomment the next two lines:\n# User-agent: *\n# Disallow: /\n"
  },
  {
    "path": "test/soundboard/accounts/api_tokens_test.exs",
    "content": "defmodule Soundboard.Accounts.ApiTokensTest do\n  use Soundboard.DataCase\n\n  import Mock\n\n  alias Soundboard.Accounts.{ApiToken, ApiTokens, User}\n  alias Soundboard.Repo\n\n  setup do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"apitok_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    %{user: user}\n  end\n\n  test \"generate, verify, revoke token lifecycle\", %{user: user} do\n    {:ok, raw, token_rec} = ApiTokens.generate_token(user, %{label: \"CI\"})\n    assert is_binary(raw) and String.starts_with?(raw, \"sb_\")\n    assert token_rec.user_id == user.id\n    assert token_rec.token == raw\n    assert token_rec.token_hash != nil\n\n    # verify returns user and updates last_used_at\n    assert {:ok, ^user, verified_token} = ApiTokens.verify_token(raw)\n    # Reload to ensure last_used_at persisted\n    reloaded = Repo.get(Soundboard.Accounts.ApiToken, verified_token.id)\n    assert reloaded.last_used_at != nil\n\n    # list_tokens includes it while active\n    assert [listed] = ApiTokens.list_tokens(user)\n    assert listed.id == token_rec.id\n\n    # revoke and ensure it's hidden and cannot verify\n    assert {:ok, _} = ApiTokens.revoke_token(user, token_rec.id)\n    assert [] == ApiTokens.list_tokens(user)\n    assert {:error, :invalid} == ApiTokens.verify_token(raw)\n  end\n\n  test \"verify_token returns error for invalid token\", %{user: _user} do\n    # ensure user created to avoid false positives\n    assert {:error, :invalid} == ApiTokens.verify_token(\"sb_invalid_token\")\n  end\n\n  test \"verify_token returns error when last_used_at update fails\", %{user: user} do\n    {:ok, raw, token} = ApiTokens.generate_token(user, %{label: \"failing-update\"})\n\n    stored_token = Repo.get!(ApiToken, token.id)\n    preloaded_token = Repo.preload(stored_token, :user)\n    failed_changeset = Ecto.Changeset.change(stored_token)\n\n    with_mock Soundboard.Repo,\n      one: fn _query -> stored_token end,\n      preload: fn ^stored_token, :user -> preloaded_token end,\n      update: fn _changeset -> {:error, failed_changeset} end do\n      assert {:error, :token_update_failed} == ApiTokens.verify_token(raw)\n    end\n  end\n\n  test \"revoke_token forbids other users\", %{user: user} do\n    {:ok, _raw, token} = ApiTokens.generate_token(user, %{label: \"owner\"})\n\n    {:ok, other} =\n      %User{}\n      |> User.changeset(%{\n        username: \"apitok_other_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive]) + 1),\n        avatar: \"a.jpg\"\n      })\n      |> Repo.insert()\n\n    assert {:error, :forbidden} == ApiTokens.revoke_token(other, token.id)\n  end\n\n  test \"list_tokens empty for new user and revoke not_found on unknown id\" do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"apitok_empty_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive]) + 2),\n        avatar: \"b.jpg\"\n      })\n      |> Repo.insert()\n\n    assert [] == ApiTokens.list_tokens(user)\n    # Passing string id should be normalized but not found\n    assert {:error, :not_found} == ApiTokens.revoke_token(user, \"999999\")\n    # Passing invalid string normalizes to -1 and should still be not_found\n    assert {:error, :not_found} == ApiTokens.revoke_token(user, \"not_an_int\")\n  end\n\n  test \"revoke_token rejects partially parsed string ids\", %{user: user} do\n    {:ok, _raw, token} = ApiTokens.generate_token(user, %{label: \"strict-id\"})\n\n    assert {:error, :not_found} == ApiTokens.revoke_token(user, \"#{token.id}garbage\")\n\n    assert [listed] = ApiTokens.list_tokens(user)\n    assert listed.id == token.id\n  end\nend\n"
  },
  {
    "path": "test/soundboard/accounts_test.exs",
    "content": "defmodule Soundboard.AccountsTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts\n  alias Soundboard.Accounts.User\n  alias Soundboard.Repo\n\n  test \"get_user/1 returns the persisted user\" do\n    user =\n      %User{}\n      |> User.changeset(%{\n        username: \"accounts_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert!()\n\n    user_id = user.id\n    assert %User{id: ^user_id} = Accounts.get_user(user.id)\n  end\n\n  test \"get_user/1 returns nil for missing users\" do\n    assert Accounts.get_user(-1) == nil\n  end\n\n  test \"avatars_by_usernames/1 returns avatars keyed by username\" do\n    user =\n      %User{}\n      |> User.changeset(%{\n        username: \"avatars_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar-keyed.png\"\n      })\n      |> Repo.insert!()\n\n    assert Accounts.avatars_by_usernames([]) == %{}\n\n    assert Accounts.avatars_by_usernames([user.username, \"missing\"]) == %{\n             user.username => \"avatar-keyed.png\"\n           }\n  end\nend\n"
  },
  {
    "path": "test/soundboard/audio_player/playback_engine_test.exs",
    "content": "defmodule Soundboard.AudioPlayer.PlaybackEngineTest do\n  use ExUnit.Case, async: false\n\n  import ExUnit.CaptureLog\n  import Mock\n\n  alias Soundboard.AudioPlayer.PlaybackEngine\n\n  setup do\n    previous_probe = Application.get_env(:soundboard, :voice_rtp_probe)\n    previous_ffmpeg = Application.get_env(:soundboard, :ffmpeg_executable, :system)\n\n    Application.put_env(:soundboard, :voice_rtp_probe, false)\n    Application.put_env(:soundboard, :ffmpeg_executable, \"/usr/bin/ffmpeg\")\n\n    on_exit(fn ->\n      if is_nil(previous_probe) do\n        Application.delete_env(:soundboard, :voice_rtp_probe)\n      else\n        Application.put_env(:soundboard, :voice_rtp_probe, previous_probe)\n      end\n\n      case previous_ffmpeg do\n        :system -> Application.delete_env(:soundboard, :ffmpeg_executable)\n        value -> Application.put_env(:soundboard, :ffmpeg_executable, value)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"joins the requested channel before playing\" do\n    test_pid = self()\n\n    with_mocks([\n      {Soundboard.AudioPlayer.SoundLibrary, [],\n       [prepare_play_input: fn \"intro.mp3\", \"/tmp/intro.mp3\" -> {\"/tmp/intro.mp3\", :url} end]},\n      {Soundboard.Discord.Voice, [],\n       [\n         channel_id: fn \"guild-1\" -> nil end,\n         join_channel: fn \"guild-1\", \"channel-9\" -> send(test_pid, :joined_channel) end,\n         ready?: fn \"guild-1\" -> true end,\n         play: fn \"guild-1\", \"/tmp/intro.mp3\", :url, [volume: 0.8] ->\n           send(test_pid, :played_sound)\n           :ok\n         end\n       ]},\n      {Soundboard.PubSubTopics, [],\n       [broadcast_sound_played: fn \"intro.mp3\", \"System\" -> send(test_pid, :broadcast_played) end]},\n      {Soundboard.Stats, [],\n       [track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}\n    ]) do\n      assert :ok =\n               PlaybackEngine.play(\n                 \"guild-1\",\n                 \"channel-9\",\n                 \"intro.mp3\",\n                 \"/tmp/intro.mp3\",\n                 0.8,\n                 \"System\"\n               )\n\n      assert_receive :joined_channel\n      assert_receive :played_sound\n      assert_receive :broadcast_played\n      refute_received :tracked_play\n    end\n  end\n\n  test \"retries after stopping already-playing audio\" do\n    test_pid = self()\n    attempt_ref = make_ref()\n    Process.put(attempt_ref, 0)\n\n    with_mocks([\n      {Soundboard.AudioPlayer.SoundLibrary, [],\n       [prepare_play_input: fn \"retry.mp3\", \"/tmp/retry.mp3\" -> {\"/tmp/retry.mp3\", :url} end]},\n      {Soundboard.Discord.Voice, [],\n       [\n         channel_id: fn \"guild-1\" -> \"channel-9\" end,\n         ready?: fn \"guild-1\" -> true end,\n         stop: fn \"guild-1\" -> send(test_pid, :stopped_audio) end,\n         play: fn \"guild-1\", \"/tmp/retry.mp3\", :url, [volume: 1.0] ->\n           case Process.get(attempt_ref, 0) do\n             0 ->\n               Process.put(attempt_ref, 1)\n               {:error, \"Audio already playing in voice channel.\"}\n\n             _ ->\n               send(test_pid, :played_after_retry)\n               :ok\n           end\n         end\n       ]},\n      {Soundboard.PubSubTopics, [],\n       [broadcast_sound_played: fn \"retry.mp3\", \"System\" -> send(test_pid, :broadcast_played) end]},\n      {Soundboard.Stats, [],\n       [track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}\n    ]) do\n      assert :ok =\n               PlaybackEngine.play(\n                 \"guild-1\",\n                 \"channel-9\",\n                 \"retry.mp3\",\n                 \"/tmp/retry.mp3\",\n                 1.0,\n                 \"System\"\n               )\n\n      assert_receive :stopped_audio\n      assert_receive :played_after_retry\n      assert_receive :broadcast_played\n      refute_received :tracked_play\n    end\n  end\n\n  test \"refreshes the current voice session after repeated encryption negotiation failures\" do\n    test_pid = self()\n    attempt_ref = make_ref()\n    ready_ref = make_ref()\n    Process.put(attempt_ref, 0)\n    Process.put(ready_ref, 0)\n\n    with_mocks([\n      {Soundboard.AudioPlayer.SoundLibrary, [],\n       [\n         prepare_play_input: fn \"refresh.mp3\", \"/tmp/refresh.mp3\" ->\n           {\"/tmp/refresh.mp3\", :url}\n         end\n       ]},\n      {Soundboard.AudioPlayer, [],\n       [current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-9\"}} end]},\n      {Soundboard.Discord.Voice, [],\n       [\n         channel_id: fn \"guild-1\" -> \"channel-9\" end,\n         ready?: fn \"guild-1\" ->\n           case Process.get(ready_ref, 0) do\n             0 ->\n               Process.put(ready_ref, 1)\n               true\n\n             1 ->\n               Process.put(ready_ref, 2)\n               false\n\n             _ ->\n               true\n           end\n         end,\n         join_channel: fn \"guild-1\", \"channel-9\" -> send(test_pid, :refreshed_voice) end,\n         play: fn \"guild-1\", \"/tmp/refresh.mp3\", :url, [volume: 1.0] ->\n           attempt = Process.get(attempt_ref, 0)\n           Process.put(attempt_ref, attempt + 1)\n\n           if attempt < 4 do\n             {:error, \"Voice session is still negotiating encryption.\"}\n           else\n             send(test_pid, :played_after_refresh)\n             :ok\n           end\n         end\n       ]},\n      {Soundboard.PubSubTopics, [],\n       [\n         broadcast_sound_played: fn \"refresh.mp3\", \"System\" ->\n           send(test_pid, :broadcast_played)\n         end,\n         broadcast_error: fn message -> flunk(\"unexpected playback error: #{message}\") end\n       ]},\n      {Soundboard.Stats, [],\n       [track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}\n    ]) do\n      assert :ok =\n               PlaybackEngine.play(\n                 \"guild-1\",\n                 \"channel-9\",\n                 \"refresh.mp3\",\n                 \"/tmp/refresh.mp3\",\n                 1.0,\n                 \"System\"\n               )\n\n      assert_receive :refreshed_voice\n      assert_receive :played_after_refresh\n      assert_receive :broadcast_played\n      refute_received :tracked_play\n    end\n  end\n\n  test \"returns an error when ffmpeg is unavailable\" do\n    test_pid = self()\n    Application.put_env(:soundboard, :ffmpeg_executable, false)\n\n    with_mocks([\n      {Soundboard.Discord.Voice, [],\n       [\n         channel_id: fn \"guild-1\" -> \"channel-9\" end,\n         ready?: fn \"guild-1\" -> true end\n       ]},\n      {Soundboard.PubSubTopics, [],\n       [\n         broadcast_error: fn \"ffmpeg is not installed on this host\" ->\n           send(test_pid, :broadcast_error)\n         end\n       ]}\n    ]) do\n      assert :error =\n               PlaybackEngine.play(\n                 \"guild-1\",\n                 \"channel-9\",\n                 \"missing-ffmpeg.mp3\",\n                 \"/tmp/missing-ffmpeg.mp3\",\n                 1.0,\n                 \"System\"\n               )\n\n      assert_receive :broadcast_error\n    end\n  end\n\n  test \"logs when voice readiness times out before playback\" do\n    log =\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.AudioPlayer.SoundLibrary, [],\n           [\n             prepare_play_input: fn \"timeout.mp3\", \"/tmp/timeout.mp3\" ->\n               {\"/tmp/timeout.mp3\", :url}\n             end\n           ]},\n          {Soundboard.Discord.Voice, [],\n           [\n             channel_id: fn \"guild-1\" -> \"channel-9\" end,\n             ready?: fn \"guild-1\" -> false end,\n             play: fn \"guild-1\", \"/tmp/timeout.mp3\", :url, [volume: 1.0] -> :ok end\n           ]},\n          {Soundboard.PubSubTopics, [],\n           [\n             broadcast_sound_played: fn _, _ -> :ok end,\n             broadcast_error: fn _ -> :ok end\n           ]},\n          {Soundboard.Stats, [], [track_play: fn _, _ -> :ok end]}\n        ]) do\n          assert :ok =\n                   PlaybackEngine.play(\n                     \"guild-1\",\n                     \"channel-9\",\n                     \"timeout.mp3\",\n                     \"/tmp/timeout.mp3\",\n                     1.0,\n                     \"System\"\n                   )\n        end\n      end)\n\n    assert log =~ \"Timed out waiting for voice readiness in guild guild-1\"\n  end\nend\n"
  },
  {
    "path": "test/soundboard/audio_player/playback_queue_test.exs",
    "content": "defmodule Soundboard.AudioPlayer.PlaybackQueueTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard.AudioPlayer.PlaybackQueue\n  alias Soundboard.AudioPlayer.State\n\n  defp base_state(overrides \\\\ []) do\n    struct!(\n      State,\n      Keyword.merge(\n        [\n          voice_channel: {\"guild-1\", \"channel-9\"},\n          current_playback: nil,\n          pending_request: nil,\n          interrupting: false,\n          interrupt_watchdog_ref: nil,\n          interrupt_watchdog_attempt: 0\n        ],\n        overrides\n      )\n    )\n  end\n\n  defp request(overrides \\\\ %{}) do\n    Map.merge(\n      %{\n        guild_id: \"guild-1\",\n        channel_id: \"channel-9\",\n        sound_name: \"intro.mp3\",\n        path_or_url: \"/tmp/intro.mp3\",\n        volume: 0.8,\n        actor: \"System\"\n      },\n      overrides\n    )\n  end\n\n  test \"build_request returns a normalized playback request\" do\n    with_mock Soundboard.AudioPlayer.SoundLibrary,\n      get_sound_path: fn \"intro.mp3\" -> {:ok, {\"/tmp/intro.mp3\", 0.8}} end do\n      assert {:ok,\n              %{\n                guild_id: \"guild-1\",\n                channel_id: \"channel-9\",\n                sound_name: \"intro.mp3\",\n                path_or_url: \"/tmp/intro.mp3\",\n                volume: 0.8,\n                actor: \"System\"\n              }} = PlaybackQueue.build_request({\"guild-1\", \"channel-9\"}, \"intro.mp3\", \"System\")\n    end\n  end\n\n  test \"build_request returns lookup errors unchanged\" do\n    with_mock Soundboard.AudioPlayer.SoundLibrary,\n      get_sound_path: fn \"missing.mp3\" -> {:error, \"Sound not found\"} end do\n      assert {:error, \"Sound not found\"} =\n               PlaybackQueue.build_request({\"guild-1\", \"channel-9\"}, \"missing.mp3\", \"System\")\n    end\n  end\n\n  test \"enqueue starts playback immediately when idle\" do\n    test_pid = self()\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"intro.mp3\", \"/tmp/intro.mp3\", 0.8, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state = PlaybackQueue.enqueue(base_state(), request(), 35)\n\n      assert %{sound_name: \"intro.mp3\", task_ref: ref, task_pid: pid} = state.current_playback\n      assert is_reference(ref)\n      assert is_pid(pid)\n      assert state.pending_request == nil\n      assert state.interrupting == false\n      assert state.interrupt_watchdog_attempt == 0\n\n      assert_receive :play_started\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"enqueue interrupts current playback and schedules watchdog when audio is still playing\" do\n    test_pid = self()\n\n    current = %{guild_id: \"guild-1\", sound_name: \"old.mp3\"}\n\n    with_mocks([\n      {Soundboard.Discord.Voice, [],\n       [\n         stop: fn \"guild-1\" -> send(test_pid, :stopped_voice) end,\n         playing?: fn \"guild-1\" -> true end\n       ]}\n    ]) do\n      state = PlaybackQueue.enqueue(base_state(current_playback: current), request(), 35)\n\n      assert_receive :stopped_voice\n      assert state.pending_request.sound_name == \"intro.mp3\"\n      assert state.interrupting == true\n      assert state.interrupt_watchdog_attempt == 1\n      assert is_reference(state.interrupt_watchdog_ref)\n    end\n  end\n\n  test \"enqueue fast-path starts pending playback when stop finishes immediately\" do\n    test_pid = self()\n\n    current = %{guild_id: \"guild-1\", sound_name: \"old.mp3\"}\n\n    with_mocks([\n      {Soundboard.Discord.Voice, [],\n       [\n         stop: fn \"guild-1\" -> send(test_pid, :stopped_voice) end,\n         playing?: fn \"guild-1\" -> false end\n       ]},\n      {Soundboard.AudioPlayer.PlaybackEngine, [],\n       [\n         play: fn \"guild-1\", \"channel-9\", \"intro.mp3\", \"/tmp/intro.mp3\", 0.8, \"System\" ->\n           send(test_pid, :play_started)\n           :ok\n         end\n       ]}\n    ]) do\n      state = PlaybackQueue.enqueue(base_state(current_playback: current), request(), 35)\n\n      assert_receive :stopped_voice\n      assert_receive :play_started\n      assert %{sound_name: \"intro.mp3\"} = state.current_playback\n      assert state.pending_request == nil\n      assert state.interrupting == false\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"clear_all resets playback, pending, and interrupt state\" do\n    timer_ref = Process.send_after(self(), :unused_watchdog, 5_000)\n\n    state =\n      base_state(\n        current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n        pending_request: request(%{sound_name: \"next.mp3\"}),\n        interrupting: true,\n        interrupt_watchdog_ref: timer_ref,\n        interrupt_watchdog_attempt: 4\n      )\n      |> PlaybackQueue.clear_all()\n\n    assert state.current_playback == nil\n    assert state.pending_request == nil\n    assert state.interrupting == false\n    assert state.interrupt_watchdog_ref == nil\n    assert state.interrupt_watchdog_attempt == 0\n  end\n\n  test \"handle_task_result marks successful playback task as completed\" do\n    current = %{\n      guild_id: \"guild-1\",\n      sound_name: \"intro.mp3\",\n      task_pid: self(),\n      task_ref: make_ref()\n    }\n\n    state =\n      base_state(current_playback: current)\n      |> PlaybackQueue.handle_task_result(:ok)\n\n    assert %{sound_name: \"intro.mp3\", task_pid: nil, task_ref: nil} = state.current_playback\n  end\n\n  test \"handle_task_result clears failed playback and starts pending request\" do\n    test_pid = self()\n\n    current = %{guild_id: \"guild-1\", sound_name: \"old.mp3\"}\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state =\n        base_state(\n          current_playback: current,\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_task_result(:error)\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.pending_request == nil\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_task_result drops pending playback when the voice channel no longer matches\" do\n    state =\n      base_state(\n        voice_channel: {\"guild-1\", \"other-channel\"},\n        current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n        pending_request: request(%{sound_name: \"next.mp3\"})\n      )\n      |> PlaybackQueue.handle_task_result(:error)\n\n    assert state.current_playback == nil\n    assert state.pending_request == nil\n  end\n\n  test \"handle_task_down clears crashed playback and starts pending request\" do\n    test_pid = self()\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state =\n        base_state(\n          current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_task_down(:boom)\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_interrupt_watchdog starts pending playback once current playback is already gone\" do\n    test_pid = self()\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state =\n        base_state(\n          interrupting: true,\n          interrupt_watchdog_attempt: 1,\n          current_playback: nil,\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_interrupt_watchdog(\"guild-1\", 1, 3, 35)\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.interrupting == false\n      assert state.interrupt_watchdog_attempt == 0\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_interrupt_watchdog retries stop and reschedules when audio is still playing\" do\n    test_pid = self()\n\n    with_mock Soundboard.Discord.Voice,\n      stop: fn \"guild-1\" -> send(test_pid, :stopped_voice) end,\n      playing?: fn \"guild-1\" -> true end do\n      state =\n        base_state(\n          interrupting: true,\n          interrupt_watchdog_attempt: 1,\n          current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n          pending_request: request()\n        )\n        |> PlaybackQueue.handle_interrupt_watchdog(\"guild-1\", 1, 3, 35)\n\n      assert_receive :stopped_voice\n      assert state.current_playback.sound_name == \"old.mp3\"\n      assert state.pending_request.sound_name == \"intro.mp3\"\n      assert state.interrupting == true\n      assert state.interrupt_watchdog_attempt == 2\n      assert is_reference(state.interrupt_watchdog_ref)\n    end\n  end\n\n  test \"handle_interrupt_watchdog clears current playback when audio is already stopped\" do\n    test_pid = self()\n\n    with_mocks([\n      {Soundboard.Discord.Voice, [], [playing?: fn \"guild-1\" -> false end]},\n      {Soundboard.AudioPlayer.PlaybackEngine, [],\n       [\n         play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n           send(test_pid, :play_started)\n           :ok\n         end\n       ]}\n    ]) do\n      state =\n        base_state(\n          interrupting: true,\n          interrupt_watchdog_attempt: 1,\n          current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_interrupt_watchdog(\"guild-1\", 1, 3, 35)\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.interrupting == false\n      assert state.interrupt_watchdog_attempt == 0\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_interrupt_watchdog forces the latest request after max attempts\" do\n    test_pid = self()\n\n    with_mocks([\n      {Soundboard.Discord.Voice, [], [stop: fn \"guild-1\" -> send(test_pid, :stopped_voice) end]},\n      {Soundboard.AudioPlayer.PlaybackEngine, [],\n       [\n         play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n           send(test_pid, :play_started)\n           :ok\n         end\n       ]}\n    ]) do\n      state =\n        base_state(\n          interrupting: true,\n          interrupt_watchdog_attempt: 3,\n          current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_interrupt_watchdog(\"guild-1\", 3, 3, 35)\n\n      assert_receive :stopped_voice\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.interrupting == false\n      assert state.interrupt_watchdog_attempt == 0\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_interrupt_watchdog is a no-op when not interrupting\" do\n    state = base_state() |> PlaybackQueue.handle_interrupt_watchdog(\"guild-1\", 1, 3, 35)\n\n    assert state == base_state()\n  end\n\n  test \"handle_playback_finished clears matching playback and starts the pending request\" do\n    test_pid = self()\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state =\n        base_state(\n          current_playback: %{guild_id: \"guild-1\", sound_name: \"old.mp3\"},\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_playback_finished(\"guild-1\")\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.pending_request == nil\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_playback_finished resumes pending playback after interrupt flow finishes\" do\n    test_pid = self()\n\n    with_mock Soundboard.AudioPlayer.PlaybackEngine,\n      play: fn \"guild-1\", \"channel-9\", \"next.mp3\", \"/tmp/next.mp3\", 0.6, \"System\" ->\n        send(test_pid, :play_started)\n        :ok\n      end do\n      state =\n        base_state(\n          voice_channel: {\"guild-1\", \"channel-9\"},\n          current_playback: %{guild_id: \"other-guild\", sound_name: \"other.mp3\"},\n          interrupting: true,\n          pending_request:\n            request(%{sound_name: \"next.mp3\", path_or_url: \"/tmp/next.mp3\", volume: 0.6})\n        )\n        |> PlaybackQueue.handle_playback_finished(\"guild-1\")\n\n      assert_receive :play_started\n      assert %{sound_name: \"next.mp3\"} = state.current_playback\n      assert state.interrupting == false\n\n      PlaybackQueue.clear_all(state)\n    end\n  end\n\n  test \"handle_playback_finished ignores unrelated guilds\" do\n    state =\n      base_state(current_playback: %{guild_id: \"guild-1\", sound_name: \"intro.mp3\"})\n      |> PlaybackQueue.handle_playback_finished(\"other-guild\")\n\n    assert state.current_playback.sound_name == \"intro.mp3\"\n  end\nend\n"
  },
  {
    "path": "test/soundboard/audio_player/sound_library_test.exs",
    "content": "defmodule Soundboard.AudioPlayer.SoundLibraryTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.AudioPlayer.SoundLibrary\n  alias Soundboard.{Repo, Sound}\n\n  setup do\n    clear_sound_cache()\n\n    on_exit(fn ->\n      clear_sound_cache()\n    end)\n\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"library_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    %{user: user}\n  end\n\n  test \"ensure_cache/0 creates the cache table and is idempotent\" do\n    assert :undefined == :ets.whereis(:sound_meta_cache)\n\n    assert :ok = SoundLibrary.ensure_cache()\n    refute :undefined == :ets.whereis(:sound_meta_cache)\n\n    assert :ok = SoundLibrary.ensure_cache()\n  end\n\n  test \"get_sound_path/1 resolves and caches URL sounds\", %{user: user} do\n    sound =\n      insert_sound!(user, %{\n        filename: unique_filename(\"remote\", \".mp3\"),\n        source_type: \"url\",\n        url: \"https://example.com/wow.mp3\",\n        volume: 0.8\n      })\n\n    assert {:ok, {\"https://example.com/wow.mp3\", 0.8}} =\n             SoundLibrary.get_sound_path(sound.filename)\n\n    Repo.delete!(sound)\n\n    assert {:ok, {\"https://example.com/wow.mp3\", 0.8}} =\n             SoundLibrary.get_sound_path(sound.filename)\n  end\n\n  test \"get_sound_path/1 resolves local sounds when the file exists\", %{user: user} do\n    filename = unique_filename(\"local\", \".wav\")\n    path = Soundboard.UploadsPath.file_path(filename)\n    File.mkdir_p!(Path.dirname(path))\n    File.write!(path, \"audio\")\n    on_exit(fn -> File.rm(path) end)\n\n    sound = insert_sound!(user, %{filename: filename, source_type: \"local\", volume: 1.2})\n\n    assert {:ok, {^path, 1.2}} = SoundLibrary.get_sound_path(sound.filename)\n  end\n\n  test \"get_sound_path/1 returns helpful errors for missing local files\", %{user: user} do\n    filename = unique_filename(\"missing\", \".mp3\")\n    sound = insert_sound!(user, %{filename: filename, source_type: \"local\"})\n\n    assert {:error, message} = SoundLibrary.get_sound_path(sound.filename)\n    assert message == \"Sound file not found at #{Soundboard.UploadsPath.file_path(filename)}\"\n  end\n\n  test \"get_sound_path/1 returns error when the sound is missing\" do\n    assert {:error, \"Sound not found\"} = SoundLibrary.get_sound_path(\"missing.mp3\")\n  end\n\n  test \"prepare_play_input/2 prefers cached source metadata\", %{user: user} do\n    sound =\n      insert_sound!(user, %{\n        filename: unique_filename(\"cached\", \".mp3\"),\n        source_type: \"url\",\n        url: \"https://example.com/cached.mp3\"\n      })\n\n    assert {:ok, {\"https://example.com/cached.mp3\", 1.0}} =\n             SoundLibrary.get_sound_path(sound.filename)\n\n    assert {\"play-this\", :url} = SoundLibrary.prepare_play_input(sound.filename, \"play-this\")\n  end\n\n  test \"prepare_play_input/2 falls back to the database when cache is empty\", %{user: user} do\n    sound =\n      insert_sound!(user, %{\n        filename: unique_filename(\"db\", \".mp3\"),\n        source_type: \"url\",\n        url: \"https://example.com/db.mp3\"\n      })\n\n    assert {\"from-db\", :url} = SoundLibrary.prepare_play_input(sound.filename, \"from-db\")\n  end\n\n  test \"invalidate_cache/1 deletes cached entries and ignores non-binary input\", %{user: user} do\n    sound =\n      insert_sound!(user, %{\n        filename: unique_filename(\"invalidate\", \".mp3\"),\n        source_type: \"url\",\n        url: \"https://example.com/invalidate.mp3\"\n      })\n\n    assert {:ok, {\"https://example.com/invalidate.mp3\", 1.0}} =\n             SoundLibrary.get_sound_path(sound.filename)\n\n    assert [{_, _}] = :ets.lookup(:sound_meta_cache, sound.filename)\n\n    assert :ok = SoundLibrary.invalidate_cache(sound.filename)\n    assert [] == :ets.lookup(:sound_meta_cache, sound.filename)\n\n    assert :ok = SoundLibrary.invalidate_cache(nil)\n  end\n\n  defp insert_sound!(user, attrs) do\n    attrs =\n      attrs\n      |> Map.put_new(:user_id, user.id)\n      |> Map.put_new(:volume, 1.0)\n\n    %Sound{}\n    |> Sound.changeset(attrs)\n    |> Repo.insert!()\n  end\n\n  defp unique_filename(prefix, ext) do\n    \"#{prefix}_#{System.unique_integer([:positive])}#{ext}\"\n  end\n\n  defp clear_sound_cache do\n    case :ets.whereis(:sound_meta_cache) do\n      :undefined -> :ok\n      _table -> :ets.delete(:sound_meta_cache)\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/bot_identity_test.exs",
    "content": "defmodule Soundboard.Discord.BotIdentityTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias EDA.API.User, as: APIUser\n  alias EDA.Cache\n  alias Soundboard.Discord.BotIdentity\n\n  test \"fetch/0 returns normalized cached user when available\" do\n    with_mocks([\n      {Cache, [], [me: fn -> %{\"id\" => \"cached-user\"} end]},\n      {APIUser, [], [me: fn -> flunk(\"API should not be called when cache is warm\") end]}\n    ]) do\n      assert {:ok, %{id: \"cached-user\"}} = BotIdentity.fetch()\n    end\n  end\n\n  test \"fetch/0 retrieves from the API and caches the user on cache miss\" do\n    test_pid = self()\n    fetched_user = %{id: \"api-user\"}\n\n    with_mocks([\n      {Cache, [],\n       [\n         me: fn -> nil end,\n         put_me: fn ^fetched_user ->\n           send(test_pid, :cached_user)\n           :ok\n         end\n       ]},\n      {APIUser, [], [me: fn -> {:ok, fetched_user} end]}\n    ]) do\n      assert {:ok, %{id: \"api-user\"}} = BotIdentity.fetch()\n      assert_receive :cached_user\n      assert_called(APIUser.me())\n      assert_called(Cache.put_me(fetched_user))\n    end\n  end\n\n  test \"fetch/0 returns non-success API responses unchanged\" do\n    with_mocks([\n      {Cache, [], [me: fn -> nil end, put_me: fn _ -> :ok end]},\n      {APIUser, [], [me: fn -> {:error, :unavailable} end]}\n    ]) do\n      assert {:error, :unavailable} = BotIdentity.fetch()\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/handler/auto_join_policy_test.exs",
    "content": "defmodule Soundboard.Discord.Handler.AutoJoinPolicyTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.Handler.AutoJoinPolicy\n\n  setup do\n    original_env = Application.get_env(:soundboard, :env)\n    original_auto_join = System.get_env(\"AUTO_JOIN\")\n\n    on_exit(fn ->\n      Application.put_env(:soundboard, :env, original_env)\n\n      if is_nil(original_auto_join) do\n        System.delete_env(\"AUTO_JOIN\")\n      else\n        System.put_env(\"AUTO_JOIN\", original_auto_join)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"mode/0 is :play in test environment regardless of AUTO_JOIN\" do\n    Application.put_env(:soundboard, :env, :test)\n    System.put_env(\"AUTO_JOIN\", \"false\")\n    assert AutoJoinPolicy.mode() == :play\n  end\n\n  test \"defaults to :play when AUTO_JOIN is not set\" do\n    Application.put_env(:soundboard, :env, :dev)\n    System.delete_env(\"AUTO_JOIN\")\n    assert AutoJoinPolicy.mode() == :play\n  end\n\n  test \"AUTO_JOIN=play returns :play\" do\n    Application.put_env(:soundboard, :env, :dev)\n    System.put_env(\"AUTO_JOIN\", \"play\")\n    assert AutoJoinPolicy.mode() == :play\n  end\n\n  test \"AUTO_JOIN=presence returns :presence\" do\n    Application.put_env(:soundboard, :env, :dev)\n    System.put_env(\"AUTO_JOIN\", \"presence\")\n    assert AutoJoinPolicy.mode() == :presence\n  end\n\n  test \"truthy values map to :presence\" do\n    Application.put_env(:soundboard, :env, :dev)\n\n    for value <- [\"true\", \"TRUE\", \"  yes \", \"1\"] do\n      System.put_env(\"AUTO_JOIN\", value)\n      assert AutoJoinPolicy.mode() == :presence\n    end\n  end\n\n  test \"falsy and unknown values map to false\" do\n    Application.put_env(:soundboard, :env, :dev)\n\n    for value <- [\"false\", \"0\", \"no\", \"never\", \"unknown\"] do\n      System.put_env(\"AUTO_JOIN\", value)\n      assert AutoJoinPolicy.mode() == false\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/handler/command_handler_test.exs",
    "content": "defmodule Soundboard.Discord.Handler.CommandHandlerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard.Discord.Handler.CommandHandler\n  alias Soundboard.Discord.Handler.VoiceRuntime\n  alias Soundboard.Discord.Message\n\n  setup do\n    original_scheme = System.get_env(\"SCHEME\")\n    System.delete_env(\"SCHEME\")\n\n    on_exit(fn ->\n      case original_scheme do\n        nil -> System.delete_env(\"SCHEME\")\n        value -> System.put_env(\"SCHEME\", value)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"!join uses the endpoint URL when building the response message\" do\n    with_mocks([\n      {VoiceRuntime, [],\n       [\n         user_voice_channel: fn \"guild-1\", \"user-1\" -> \"voice-1\" end,\n         join_voice_channel: fn \"guild-1\", \"voice-1\" -> :ok end\n       ]},\n      {Message, [],\n       [\n         create: fn channel_id, body ->\n           send(self(), {:created_message, channel_id, body})\n           :ok\n         end\n       ]}\n    ]) do\n      CommandHandler.handle_message(%{\n        content: \"!join\",\n        guild_id: \"guild-1\",\n        channel_id: \"text-1\",\n        author: %{id: \"user-1\"}\n      })\n\n      assert_receive {:created_message, \"text-1\", body}\n      assert body =~ \"Joined your voice channel!\"\n      assert body =~ SoundboardWeb.Endpoint.url()\n      refute body =~ \"nil://\"\n    end\n  end\n\n  test \"!leave leaves the current voice channel and confirms in chat\" do\n    with_mocks([\n      {VoiceRuntime, [], [leave_voice_channel: fn \"guild-1\" -> :ok end]},\n      {Message, [],\n       [\n         create: fn channel_id, body ->\n           send(self(), {:created_message, channel_id, body})\n           :ok\n         end\n       ]}\n    ]) do\n      assert :ok =\n               CommandHandler.handle_message(%{\n                 content: \"!leave\",\n                 guild_id: \"guild-1\",\n                 channel_id: \"text-1\"\n               })\n\n      assert_receive {:created_message, \"text-1\", \"Left the voice channel!\"}\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/handler/idle_timeout_policy_test.exs",
    "content": "defmodule Soundboard.Discord.Handler.IdleTimeoutPolicyTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.Handler.IdleTimeoutPolicy\n\n  setup do\n    original = System.get_env(\"VOICE_IDLE_TIMEOUT_SECONDS\")\n\n    on_exit(fn ->\n      if is_nil(original) do\n        System.delete_env(\"VOICE_IDLE_TIMEOUT_SECONDS\")\n      else\n        System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", original)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"defaults to 600 seconds (10 minutes) when env var is not set\" do\n    System.delete_env(\"VOICE_IDLE_TIMEOUT_SECONDS\")\n    assert IdleTimeoutPolicy.timeout_ms() == 600 * 1_000\n  end\n\n  test \"reads VOICE_IDLE_TIMEOUT_SECONDS from environment\" do\n    System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", \"300\")\n    assert IdleTimeoutPolicy.timeout_ms() == 300 * 1_000\n  end\n\n  test \"handles whitespace around the value\" do\n    System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", \"  30  \")\n    assert IdleTimeoutPolicy.timeout_ms() == 30 * 1_000\n  end\n\n  test \"returns nil when set to 0 (disabled)\" do\n    System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", \"0\")\n    assert IdleTimeoutPolicy.timeout_ms() == nil\n  end\n\n  test \"returns nil when set to a negative value\" do\n    System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", \"-1\")\n    assert IdleTimeoutPolicy.timeout_ms() == nil\n  end\n\n  test \"falls back to default when value is non-numeric\" do\n    System.put_env(\"VOICE_IDLE_TIMEOUT_SECONDS\", \"ten\")\n    assert IdleTimeoutPolicy.timeout_ms() == 600 * 1_000\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/handler/voice_presence_test.exs",
    "content": "defmodule Soundboard.Discord.Handler.VoicePresenceTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard.Discord.GuildCache\n  alias Soundboard.Discord.Handler.VoicePresence\n\n  describe \"find_user_voice_channel/1\" do\n    test \"returns {:ok, {guild_id, channel_id}} when user is in a voice channel\" do\n      guilds = [\n        %{\n          id: \"guild-1\",\n          voice_states: [\n            %{user_id: \"user-99\", channel_id: \"ch-5\", guild_id: \"guild-1\", session_id: \"s1\"}\n          ]\n        }\n      ]\n\n      with_mock GuildCache, all: fn -> guilds end do\n        assert VoicePresence.find_user_voice_channel(\"user-99\") == {:ok, {\"guild-1\", \"ch-5\"}}\n      end\n    end\n\n    test \"returns :not_found when user is in no guild\" do\n      guilds = [\n        %{\n          id: \"guild-1\",\n          voice_states: [\n            %{user_id: \"other-user\", channel_id: \"ch-5\", guild_id: \"guild-1\", session_id: \"s1\"}\n          ]\n        }\n      ]\n\n      with_mock GuildCache, all: fn -> guilds end do\n        assert VoicePresence.find_user_voice_channel(\"user-99\") == :not_found\n      end\n    end\n\n    test \"returns :not_found when user has no channel_id\" do\n      guilds = [\n        %{\n          id: \"guild-1\",\n          voice_states: [\n            %{user_id: \"user-99\", channel_id: nil, guild_id: \"guild-1\", session_id: \"s1\"}\n          ]\n        }\n      ]\n\n      with_mock GuildCache, all: fn -> guilds end do\n        assert VoicePresence.find_user_voice_channel(\"user-99\") == :not_found\n      end\n    end\n\n    test \"searches across multiple guilds\" do\n      guilds = [\n        %{id: \"guild-1\", voice_states: []},\n        %{\n          id: \"guild-2\",\n          voice_states: [\n            %{user_id: \"user-99\", channel_id: \"ch-7\", guild_id: \"guild-2\", session_id: \"s2\"}\n          ]\n        }\n      ]\n\n      with_mock GuildCache, all: fn -> guilds end do\n        assert VoicePresence.find_user_voice_channel(\"user-99\") == {:ok, {\"guild-2\", \"ch-7\"}}\n      end\n    end\n\n    test \"returns :not_found when guild cache is unavailable\" do\n      with_mock GuildCache, all: fn -> raise \"cache unavailable\" end do\n        assert VoicePresence.find_user_voice_channel(\"user-99\") == :not_found\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/handler/voice_runtime_test.exs",
    "content": "defmodule Soundboard.Discord.Handler.VoiceRuntimeTest do\n  use ExUnit.Case, async: false\n\n  import ExUnit.CaptureLog\n  import Mock\n\n  alias Soundboard.AudioPlayer\n  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoicePresence, VoiceRuntime}\n\n  test \"handle_disconnect returns a recheck action instead of scheduling directly\" do\n    payload = %{guild_id: \"guild-1\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> :presence end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end,\n         users_in_channel: fn \"guild-1\", \"channel-1\" -> {:ok, 2} end\n       ]}\n    ]) do\n      assert VoiceRuntime.handle_disconnect(payload) == [\n               {:schedule_recheck_alone, \"guild-1\", \"channel-1\", 1_500}\n             ]\n    end\n  end\n\n  test \"handle_connect returns a recheck action when the bot is still sharing another channel\" do\n    payload = %{guild_id: \"guild-1\", channel_id: \"channel-2\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> :presence end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end,\n         users_in_channel: fn \"guild-1\", \"channel-1\" -> {:ok, 3} end\n       ]}\n    ]) do\n      assert VoiceRuntime.handle_connect(payload) == [\n               {:schedule_recheck_alone, \"guild-1\", \"channel-1\", 1_500}\n             ]\n    end\n  end\n\n  test \"recheck_alone logs and returns no actions when voice state is unavailable\" do\n    log =\n      capture_log(fn ->\n        with_mocks([\n          {VoicePresence, [],\n           [\n             current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end,\n             users_in_channel: fn \"guild-1\", \"channel-1\" -> {:error, :unavailable} end\n           ]}\n        ]) do\n          assert VoiceRuntime.recheck_alone(\"guild-1\", \"channel-1\") == []\n        end\n      end)\n\n    assert log =~ \"Recheck skipped because voice state was unavailable\"\n  end\n\n  test \"bootstrap scans cached guilds and joins an active voice channel\" do\n    test_pid = self()\n\n    guild = %{\n      id: \"guild-1\",\n      voice_states: [\n        %{user_id: \"user-1\", channel_id: nil},\n        %{user_id: \"user-2\", channel_id: \"channel-9\"}\n      ]\n    }\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> :presence end]},\n      {VoicePresence, [],\n       [cached_guilds: fn -> {:ok, [guild]} end, bot_id: fn -> {:ok, \"bot-1\"} end]},\n      {Soundboard.Discord.Voice, [],\n       [join_channel: fn \"guild-1\", \"channel-9\" -> send(test_pid, :joined_bootstrap_channel) end]},\n      {AudioPlayer, [],\n       [set_voice_channel: fn \"guild-1\", \"channel-9\" -> send(test_pid, :set_bootstrap_voice) end]}\n    ]) do\n      assert :ok = VoiceRuntime.bootstrap()\n      assert_receive :joined_bootstrap_channel, 6_000\n      assert_receive :set_bootstrap_voice, 1_000\n    end\n  end\n\n  test \"bootstrap skips guild check in play mode\" do\n    with_mock AutoJoinPolicy, mode: fn -> :play end do\n      assert :ok = VoiceRuntime.bootstrap()\n      # No Task spawned, nothing to assert — just verify it returns :ok without hanging\n    end\n  end\n\n  test \"handle_disconnect notifies AudioPlayer when bot is alone in play mode\" do\n    test_pid = self()\n    payload = %{guild_id: \"guild-1\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> :play end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end,\n         users_in_channel: fn \"guild-1\", \"channel-1\" -> {:ok, 0} end\n       ]},\n      {AudioPlayer, [],\n       [\n         last_user_left: fn \"guild-1\" ->\n           send(test_pid, :last_user_left_called)\n         end\n       ]}\n    ]) do\n      VoiceRuntime.handle_disconnect(payload)\n      assert_receive :last_user_left_called, 1_000\n    end\n  end\n\n  test \"handle_disconnect notifies AudioPlayer when bot is alone in false mode\" do\n    test_pid = self()\n    payload = %{guild_id: \"guild-1\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> false end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end,\n         users_in_channel: fn \"guild-1\", \"channel-1\" -> {:ok, 0} end\n       ]},\n      {AudioPlayer, [],\n       [\n         last_user_left: fn \"guild-1\" ->\n           send(test_pid, :last_user_left_called)\n         end\n       ]}\n    ]) do\n      VoiceRuntime.handle_disconnect(payload)\n      assert_receive :last_user_left_called, 1_000\n    end\n  end\n\n  test \"handle_connect in false mode cancels idle timer when user joins bot's channel\" do\n    test_pid = self()\n    payload = %{guild_id: \"guild-1\", channel_id: \"channel-1\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> false end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end\n       ]},\n      {AudioPlayer, [],\n       [\n         user_joined_channel: fn \"guild-1\" ->\n           send(test_pid, :user_joined_called)\n         end\n       ]}\n    ]) do\n      VoiceRuntime.handle_connect(payload)\n      assert_receive :user_joined_called, 1_000\n    end\n  end\n\n  test \"handle_connect in false mode ignores joins to other channels\" do\n    payload = %{guild_id: \"guild-1\", channel_id: \"channel-2\", user_id: \"user-1\"}\n\n    with_mocks([\n      {AutoJoinPolicy, [], [mode: fn -> false end]},\n      {VoicePresence, [],\n       [\n         bot_user?: fn \"user-1\" -> false end,\n         current_voice_channel: fn -> {:ok, {\"guild-1\", \"channel-1\"}} end\n       ]},\n      {AudioPlayer, [], [user_joined_channel: fn _ -> :ok end]}\n    ]) do\n      VoiceRuntime.handle_connect(payload)\n      refute called(AudioPlayer.user_joined_channel(:_))\n    end\n  end\n\n  test \"handle_disconnect ignores the bot's own voice disconnect\" do\n    payload = %{guild_id: \"guild-1\", user_id: \"bot-999\"}\n\n    with_mocks([\n      {VoicePresence, [], [bot_user?: fn \"bot-999\" -> true end]},\n      {AudioPlayer, [], [last_user_left: fn _ -> :ok end]}\n    ]) do\n      assert VoiceRuntime.handle_disconnect(payload) == []\n      refute called(AudioPlayer.last_user_left(:_))\n    end\n  end\n\n  test \"handle_connect returns [] in play mode\" do\n    payload = %{guild_id: \"guild-1\", channel_id: \"channel-1\", user_id: \"user-1\"}\n\n    with_mock AutoJoinPolicy, mode: fn -> :play end do\n      assert VoiceRuntime.handle_connect(payload) == []\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/role_checker_test.exs",
    "content": "defmodule Soundboard.Discord.RoleCheckerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias EDA.API.Member\n  alias Soundboard.Discord.RoleChecker\n\n  setup do\n    previous_guild = Application.get_env(:soundboard, :required_guild_id)\n    previous_roles = Application.get_env(:soundboard, :required_role_ids)\n\n    on_exit(fn ->\n      restore_env(:required_guild_id, previous_guild)\n      restore_env(:required_role_ids, previous_roles || [])\n    end)\n\n    :ok\n  end\n\n  defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)\n  defp restore_env(key, value), do: Application.put_env(:soundboard, key, value)\n\n  describe \"feature_enabled?/0\" do\n    test \"returns false when guild_id is missing\" do\n      Application.put_env(:soundboard, :required_guild_id, nil)\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n      refute RoleChecker.feature_enabled?()\n    end\n\n    test \"returns false when role_ids is empty\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [])\n\n      refute RoleChecker.feature_enabled?()\n    end\n\n    test \"returns true when both guild_id and role_ids are configured\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n      assert RoleChecker.feature_enabled?()\n    end\n  end\n\n  describe \"authorized?/1\" do\n    test \"returns true when feature is disabled and does not call the API\" do\n      Application.put_env(:soundboard, :required_guild_id, nil)\n      Application.put_env(:soundboard, :required_role_ids, [])\n\n      with_mock Member, get: fn _, _ -> flunk(\"API should not be called when disabled\") end do\n        assert RoleChecker.authorized?(\"user1\")\n      end\n    end\n\n    test \"returns true when member has at least one required role\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\", \"r2\"])\n\n      with_mock Member,\n        get: fn \"g1\", \"user1\" -> {:ok, %{\"roles\" => [\"other\", \"r2\"]}} end do\n        assert RoleChecker.authorized?(\"user1\")\n        assert_called(Member.get(\"g1\", \"user1\"))\n      end\n    end\n\n    test \"returns false when member has none of the required roles\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n      with_mock Member,\n        get: fn \"g1\", \"user1\" -> {:ok, %{\"roles\" => [\"other_role\"]}} end do\n        refute RoleChecker.authorized?(\"user1\")\n      end\n    end\n\n    test \"returns false when API returns an error\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n      with_mock Member, get: fn _, _ -> {:error, :not_found} end do\n        refute RoleChecker.authorized?(\"user1\")\n      end\n    end\n\n    test \"returns false when API response shape is unexpected\" do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n      with_mock Member, get: fn _, _ -> {:ok, %{\"unexpected\" => \"shape\"}} end do\n        refute RoleChecker.authorized?(\"user1\")\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/runtime_capability_test.exs",
    "content": "defmodule Soundboard.Discord.RuntimeCapabilityTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.RuntimeCapability\n\n  setup do\n    original_env = Application.get_env(:soundboard, :env)\n    original_dave = Application.get_env(:eda, :dave)\n\n    on_exit(fn ->\n      Application.put_env(:soundboard, :env, original_env)\n      Application.put_env(:eda, :dave, original_dave)\n    end)\n\n    :ok\n  end\n\n  test \"voice runtime is always available in test\" do\n    Application.put_env(:soundboard, :env, :test)\n    Application.put_env(:eda, :dave, true)\n\n    assert :ok = RuntimeCapability.voice_runtime_status()\n    refute RuntimeCapability.discord_handler_enabled?()\n  end\n\n  test \"voice runtime is available when dave is disabled\" do\n    Application.put_env(:soundboard, :env, :dev)\n    Application.put_env(:eda, :dave, false)\n\n    assert :ok = RuntimeCapability.voice_runtime_status()\n    assert RuntimeCapability.discord_handler_enabled?()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/discord/voice_test.exs",
    "content": "defmodule Soundboard.Discord.VoiceTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.Voice\n\n  defmodule VoiceModuleWithPlay4 do\n    def play(guild_id, input, type, opts) do\n      Process.put(:voice_play_args, {guild_id, input, type, opts})\n      :ok\n    end\n  end\n\n  defmodule VoiceModuleWithPlay3 do\n    def play(guild_id, input, type) do\n      Process.put(:voice_play_args, {guild_id, input, type})\n      :ok\n    end\n  end\n\n  setup do\n    previous_module = Application.get_env(:soundboard, :eda_voice_module)\n    Process.delete(:voice_play_args)\n\n    on_exit(fn ->\n      Process.delete(:voice_play_args)\n\n      if is_nil(previous_module) do\n        Application.delete_env(:soundboard, :eda_voice_module)\n      else\n        Application.put_env(:soundboard, :eda_voice_module, previous_module)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"uses play/4 when the configured voice module supports it\" do\n    Application.put_env(:soundboard, :eda_voice_module, VoiceModuleWithPlay4)\n\n    assert :ok = Voice.play(123, \"file.mp3\", :url, volume: 1.2)\n    assert Process.get(:voice_play_args) == {\"123\", \"file.mp3\", :url, [volume: 1.2]}\n  end\n\n  test \"falls back to play/3 and drops opts when only play/3 is available\" do\n    Application.put_env(:soundboard, :eda_voice_module, VoiceModuleWithPlay3)\n\n    assert :ok = Voice.play(123, \"file.mp3\", :url, volume: 1.2)\n    assert Process.get(:voice_play_args) == {\"123\", \"file.mp3\", :url}\n  end\nend\n"
  },
  {
    "path": "test/soundboard/favorites_test.exs",
    "content": "defmodule Soundboard.FavoritesTest do\n  @moduledoc \"\"\"\n  Test for the Favorites module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.{Accounts.User, Favorites, Sound}\n\n  describe \"favorites\" do\n    setup do\n      user = insert_user()\n      sound = insert_sound(user)\n      %{user: user, sound: sound}\n    end\n\n    test \"list_favorites/1 returns all favorites for a user\", %{user: user, sound: sound} do\n      {:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)\n      assert [sound.id] == Favorites.list_favorites(user.id)\n    end\n\n    test \"toggle_favorite/2 adds a favorite when it doesn't exist\", %{user: user, sound: sound} do\n      assert {:ok, favorite} = Favorites.toggle_favorite(user.id, sound.id)\n      assert favorite.user_id == user.id\n      assert favorite.sound_id == sound.id\n    end\n\n    test \"toggle_favorite/2 removes a favorite when it exists\", %{user: user, sound: sound} do\n      {:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)\n      {:ok, deleted_favorite} = Favorites.toggle_favorite(user.id, sound.id)\n      assert deleted_favorite.__meta__.state == :deleted\n      assert [] == Favorites.list_favorites(user.id)\n    end\n\n    test \"favorite?/2 returns true when favorite exists\", %{user: user, sound: sound} do\n      refute Favorites.favorite?(user.id, sound.id)\n      {:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)\n      assert Favorites.favorite?(user.id, sound.id)\n    end\n\n    test \"max_favorites/0 returns the maximum number of favorites allowed\" do\n      assert Favorites.max_favorites() == 16\n    end\n\n    test \"cannot add more favorites than max_favorites\", %{user: user} do\n      # Create max_favorites + 1 number of sounds\n      sounds = Enum.map(1..(Favorites.max_favorites() + 1), fn _ -> insert_sound(user) end)\n\n      # Add max_favorites successfully\n      Enum.each(Enum.take(sounds, Favorites.max_favorites()), fn sound ->\n        assert {:ok, _} = Favorites.toggle_favorite(user.id, sound.id)\n      end)\n\n      # Try to add one more favorite - should fail\n      last_sound = List.last(sounds)\n\n      assert {:error, changeset} = Favorites.toggle_favorite(user.id, last_sound.id)\n      assert \"You can only have 16 favorites\" in errors_on(changeset).base\n    end\n\n    test \"toggle_favorite/2 rejects missing sounds\", %{user: user} do\n      assert {:error, changeset} = Favorites.toggle_favorite(user.id, -1)\n      assert \"does not exist\" in errors_on(changeset).sound\n    end\n  end\n\n  # Helper functions\n  defp insert_user(attrs \\\\ %{}) do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(\n        Map.merge(\n          %{\n            username: \"testuser\",\n            discord_id: \"123456789\",\n            avatar: \"test_avatar.jpg\"\n          },\n          attrs\n        )\n      )\n      |> Repo.insert()\n\n    user\n  end\n\n  defp insert_sound(user, attrs \\\\ %{}) do\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(\n        Map.merge(\n          %{\n            filename: \"test_sound#{System.unique_integer()}.mp3\",\n            source_type: \"local\",\n            user_id: user.id\n          },\n          attrs\n        )\n      )\n      |> Repo.insert()\n\n    sound\n  end\nend\n"
  },
  {
    "path": "test/soundboard/migrations/data_migrations_test.exs",
    "content": "for migration_file <- [\n      \"20250101213201_create_sounds.exs\",\n      \"20250101213717_create_tags.exs\",\n      \"20250101231744_create_users.exs\",\n      \"20250102212120_create_plays.exs\",\n      \"20250102212121_create_favorites.exs\",\n      \"20250102212122_add_user_id_to_sounds.exs\",\n      \"20250102212123_change_favorites_filename_to_sound_id.exs\",\n      \"20260306150000_add_sound_id_to_plays.exs\",\n      \"20260306151000_finalize_favorites_and_sound_tags_migrations.exs\",\n      \"20260307211000_rename_sound_name_to_played_filename_in_plays.exs\"\n    ] do\n  Code.require_file(Path.expand(\"../../../priv/repo/migrations/#{migration_file}\", __DIR__))\nend\n\ndefmodule Soundboard.Migrations.DataMigrationsTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Repo.Migrations.{\n    AddSoundIdToPlays,\n    AddUserIdToSounds,\n    ChangeFavoritesFilenameToSoundId,\n    CreateFavorites,\n    CreatePlays,\n    CreateSounds,\n    CreateTags,\n    CreateUsers,\n    FinalizeFavoritesAndSoundTagsMigrations,\n    RenameSoundNameToPlayedFilenameInPlays\n  }\n\n  defmodule MigrationRepo do\n    use Ecto.Repo,\n      otp_app: :soundboard,\n      adapter: Ecto.Adapters.SQLite3\n  end\n\n  setup do\n    db_path =\n      Path.join(\n        System.tmp_dir!(),\n        \"soundboard-migration-#{System.unique_integer([:positive])}.db\"\n      )\n\n    {:ok, pid} = MigrationRepo.start_link(database: db_path, pool_size: 1, name: nil)\n    previous_repo = MigrationRepo.put_dynamic_repo(pid)\n\n    on_exit(fn ->\n      MigrationRepo.put_dynamic_repo(previous_repo)\n      Process.exit(pid, :normal)\n      File.rm(db_path)\n    end)\n\n    %{repo: MigrationRepo}\n  end\n\n  test \"add_sound_id_to_plays backfills matching sound ids and rolls back cleanly\", %{repo: repo} do\n    migrate_up(repo, [\n      {20_250_101_213_201, CreateSounds},\n      {20_250_101_231_744, CreateUsers},\n      {20_250_102_212_120, CreatePlays},\n      {20_250_102_212_122, AddUserIdToSounds}\n    ])\n\n    repo.query!(\"\"\"\n    INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)\n    VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)\n    VALUES (1, 'beep.mp3', '[]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO plays (id, sound_name, user_id, inserted_at, updated_at)\n    VALUES (1, 'beep.mp3', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    :ok = Ecto.Migrator.up(repo, 20_260_306_150_000, AddSoundIdToPlays, log: false)\n\n    assert column_names(repo, \"plays\") |> Enum.member?(\"sound_id\")\n    assert [[1]] = repo.query!(\"SELECT sound_id FROM plays WHERE id = 1\").rows\n\n    :ok = Ecto.Migrator.down(repo, 20_260_306_150_000, AddSoundIdToPlays, log: false)\n\n    refute column_names(repo, \"plays\") |> Enum.member?(\"sound_id\")\n  end\n\n  test \"rename_sound_name_to_played_filename_in_plays renames the column and rolls back cleanly\",\n       %{repo: repo} do\n    migrate_up(repo, [\n      {20_250_101_213_201, CreateSounds},\n      {20_250_101_231_744, CreateUsers},\n      {20_250_102_212_120, CreatePlays},\n      {20_250_102_212_122, AddUserIdToSounds},\n      {20_260_306_150_000, AddSoundIdToPlays}\n    ])\n\n    repo.query!(\"\"\"\n    INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)\n    VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)\n    VALUES (1, 'beep.mp3', '[]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO plays (id, sound_name, user_id, inserted_at, updated_at)\n    VALUES (1, 'beep.mp3', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    :ok =\n      Ecto.Migrator.up(\n        repo,\n        20_260_307_211_000,\n        RenameSoundNameToPlayedFilenameInPlays,\n        log: false\n      )\n\n    assert column_names(repo, \"plays\") |> Enum.member?(\"played_filename\")\n    refute column_names(repo, \"plays\") |> Enum.member?(\"sound_name\")\n\n    assert [[\"beep.mp3\", 1]] =\n             repo.query!(\"SELECT played_filename, sound_id FROM plays WHERE id = 1\").rows\n\n    :ok =\n      Ecto.Migrator.down(\n        repo,\n        20_260_307_211_000,\n        RenameSoundNameToPlayedFilenameInPlays,\n        log: false\n      )\n\n    assert column_names(repo, \"plays\") |> Enum.member?(\"sound_name\")\n    refute column_names(repo, \"plays\") |> Enum.member?(\"played_filename\")\n  end\n\n  test \"finalize favorites and sound tags backfills legacy tags and restores them on rollback\", %{\n    repo: repo\n  } do\n    migrate_up(repo, [\n      {20_250_101_213_201, CreateSounds},\n      {20_250_101_213_717, CreateTags},\n      {20_250_101_231_744, CreateUsers},\n      {20_250_102_212_121, CreateFavorites},\n      {20_250_102_212_122, AddUserIdToSounds},\n      {20_250_102_212_123, ChangeFavoritesFilenameToSoundId}\n    ])\n\n    repo.query!(\"\"\"\n    INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)\n    VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)\n    VALUES (1, 'beep.mp3', '[\" meme \",\"MEME\",\"alert\",\"\"]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO tags (id, name, inserted_at, updated_at)\n    VALUES (1, 'meme', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at)\n    VALUES (1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    repo.query!(\"\"\"\n    INSERT INTO favorites (user_id, sound_id, inserted_at, updated_at)\n    VALUES (1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),\n           (1, 999, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)\n    \"\"\")\n\n    :ok =\n      Ecto.Migrator.up(\n        repo,\n        20_260_306_151_000,\n        FinalizeFavoritesAndSoundTagsMigrations,\n        log: false\n      )\n\n    refute column_names(repo, \"sounds\") |> Enum.member?(\"tags\")\n\n    assert [[\"alert\"], [\"meme\"]] =\n             repo.query!(\"SELECT name FROM tags ORDER BY name\").rows\n\n    assert [[1, 1]] =\n             repo.query!(\"SELECT user_id, sound_id FROM favorites ORDER BY sound_id\").rows\n\n    assert [[1, 1], [1, 2]] =\n             repo.query!(\"SELECT sound_id, tag_id FROM sound_tags ORDER BY tag_id\").rows\n\n    :ok =\n      Ecto.Migrator.down(\n        repo,\n        20_260_306_151_000,\n        FinalizeFavoritesAndSoundTagsMigrations,\n        log: false\n      )\n\n    assert column_names(repo, \"sounds\") |> Enum.member?(\"tags\")\n    assert [[\"[\\\"alert\\\",\\\"meme\\\"]\"]] = repo.query!(\"SELECT tags FROM sounds WHERE id = 1\").rows\n  end\n\n  defp migrate_up(repo, migrations) do\n    Enum.each(migrations, fn {version, migration} ->\n      :ok = Ecto.Migrator.up(repo, version, migration, log: false)\n    end)\n  end\n\n  defp column_names(repo, table_name) do\n    repo.query!(\"PRAGMA table_info(#{table_name})\")\n    |> Map.fetch!(:rows)\n    |> Enum.map(fn [_cid, name | _rest] -> name end)\n  end\nend\n"
  },
  {
    "path": "test/soundboard/public_url_test.exs",
    "content": "defmodule Soundboard.PublicURLTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.PublicURL\n\n  test \"current/0 returns the endpoint base URL\" do\n    assert PublicURL.current() == SoundboardWeb.Endpoint.url()\n  end\n\n  test \"from_uri_or_current/1 keeps the request host and strips default ports\" do\n    assert PublicURL.from_uri_or_current(\"https://soundboard.example:443/settings\") ==\n             \"https://soundboard.example\"\n\n    assert PublicURL.from_uri_or_current(\"http://localhost:80/settings\") ==\n             \"http://localhost\"\n  end\n\n  test \"from_uri_or_current/1 preserves non-default ports\" do\n    assert PublicURL.from_uri_or_current(\"http://localhost:4000/settings\") ==\n             \"http://localhost:4000\"\n  end\n\n  test \"from_uri_or_current/1 falls back to the configured public URL for invalid input\" do\n    assert PublicURL.from_uri_or_current(nil) == SoundboardWeb.Endpoint.url()\n    assert PublicURL.from_uri_or_current(\"not a uri\") == SoundboardWeb.Endpoint.url()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/pubsub_topics_test.exs",
    "content": "defmodule Soundboard.PubSubTopicsTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.PubSubTopics\n\n  test \"exposes canonical topic names\" do\n    assert PubSubTopics.files_topic() == \"soundboard.files\"\n    assert PubSubTopics.playback_topic() == \"soundboard.playback\"\n    assert PubSubTopics.stats_topic() == \"soundboard.stats\"\n  end\n\n  test \"broadcast helpers publish to subscribed topics\" do\n    PubSubTopics.subscribe_files()\n    PubSubTopics.subscribe_playback()\n    PubSubTopics.subscribe_stats()\n\n    assert :ok = PubSubTopics.broadcast_files_updated()\n    assert_receive {:files_updated}\n\n    assert :ok = PubSubTopics.broadcast_sound_played(\"wow.mp3\", \"tester\")\n    assert_receive {:sound_played, %{filename: \"wow.mp3\", played_by: \"tester\"}}\n\n    assert :ok = PubSubTopics.broadcast_error(\"boom\")\n    assert_receive {:error, \"boom\"}\n\n    assert :ok = PubSubTopics.broadcast_stats_updated()\n    assert_receive {:stats_updated}\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sound_tag_test.exs",
    "content": "defmodule Soundboard.SoundTagTest do\n  @moduledoc \"\"\"\n  Test for the SoundTag module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Sound, SoundTag, Tag}\n\n  describe \"sound_tags\" do\n    test \"changeset with valid attributes\" do\n      sound = insert_sound()\n      tag = insert_tag()\n\n      attrs = %{\n        sound_id: sound.id,\n        tag_id: tag.id\n      }\n\n      changeset = SoundTag.changeset(%SoundTag{}, attrs)\n      assert changeset.valid?\n      assert {:ok, sound_tag} = Repo.insert(changeset)\n      assert sound_tag.sound_id == sound.id\n      assert sound_tag.tag_id == tag.id\n    end\n\n    test \"changeset enforces unique constraint\" do\n      sound = insert_sound()\n      tag = insert_tag()\n      attrs = %{sound_id: sound.id, tag_id: tag.id}\n\n      # First insert succeeds\n      {:ok, _} = Repo.insert(SoundTag.changeset(%SoundTag{}, attrs))\n\n      changeset = SoundTag.changeset(%SoundTag{}, attrs)\n      {:error, changeset} = Repo.insert(changeset)\n\n      assert {\"has already been taken\", _} = changeset.errors[:sound_id]\n    end\n\n    test \"changeset requires sound_id and tag_id\" do\n      changeset = SoundTag.changeset(%SoundTag{}, %{})\n      refute changeset.valid?\n      assert \"can't be blank\" in errors_on(changeset).sound_id\n      assert \"can't be blank\" in errors_on(changeset).tag_id\n    end\n  end\n\n  # Helper functions\n  defp insert_sound do\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test_sound#{System.unique_integer()}.mp3\",\n        source_type: \"local\",\n        user_id: insert_user().id\n      })\n      |> Repo.insert()\n\n    sound\n  end\n\n  defp insert_tag do\n    {:ok, tag} =\n      %Tag{}\n      |> Tag.changeset(%{name: \"test_tag#{System.unique_integer()}\"})\n      |> Repo.insert()\n\n    tag\n  end\n\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser#{System.unique_integer()}\",\n        discord_id: \"123456789\",\n        avatar: \"test_avatar.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sound_test.exs",
    "content": "defmodule Soundboard.SoundTest do\n  @moduledoc \"\"\"\n  Tests the Sound module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound, Sounds, Tag, UserSoundSetting}\n\n  describe \"changeset validation\" do\n    test \"validates required fields\" do\n      changeset = Sound.changeset(%Sound{}, %{})\n\n      assert errors_on(changeset) == %{\n               filename: [\"can't be blank\"],\n               user_id: [\"can't be blank\"]\n             }\n    end\n\n    test \"validates local sound requires filename\" do\n      changeset =\n        Sound.changeset(%Sound{}, %{\n          user_id: 1,\n          source_type: \"local\"\n        })\n\n      assert \"can't be blank\" in errors_on(changeset).filename\n    end\n\n    test \"validates url sound requires url\" do\n      changeset =\n        Sound.changeset(%Sound{}, %{\n          user_id: 1,\n          source_type: \"url\"\n        })\n\n      assert \"can't be blank\" in errors_on(changeset).url\n    end\n\n    test \"validates source type values\" do\n      changeset =\n        Sound.changeset(%Sound{}, %{\n          user_id: 1,\n          source_type: \"invalid\"\n        })\n\n      assert \"must be either 'local' or 'url'\" in errors_on(changeset).source_type\n    end\n\n    test \"enforces unique filenames\" do\n      user = insert_user()\n      attrs = %{filename: \"test.mp3\", source_type: \"local\", user_id: user.id}\n\n      {:ok, _} = %Sound{} |> Sound.changeset(attrs) |> Repo.insert()\n      {:error, changeset} = %Sound{} |> Sound.changeset(attrs) |> Repo.insert()\n\n      assert \"has already been taken\" in errors_on(changeset).filename\n    end\n\n    test \"validates volume between 0 and 1.5\" do\n      user = insert_user()\n\n      high_changeset =\n        Sound.changeset(%Sound{}, %{\n          filename: \"loud.mp3\",\n          source_type: \"local\",\n          user_id: user.id,\n          volume: 1.6\n        })\n\n      assert Enum.any?(\n               errors_on(high_changeset).volume,\n               &String.contains?(&1, \"less than or equal\")\n             )\n\n      low_changeset =\n        Sound.changeset(%Sound{}, %{\n          filename: \"quiet.mp3\",\n          source_type: \"local\",\n          user_id: user.id,\n          volume: -0.1\n        })\n\n      assert Enum.any?(\n               errors_on(low_changeset).volume,\n               &String.contains?(&1, \"greater than or equal\")\n             )\n    end\n  end\n\n  setup do\n    user = insert_user()\n    {:ok, tag} = %Tag{name: \"test_tag\"} |> Tag.changeset(%{}) |> Repo.insert()\n    {:ok, sound} = insert_sound(user)\n    %{user: user, sound: sound, tag: tag}\n  end\n\n  describe \"tag associations\" do\n    test \"can associate tags through changeset\", %{user: user, tag: tag} do\n      attrs = %{\n        filename: \"test_sound_new.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      }\n\n      {:ok, sound} =\n        %Sound{}\n        |> Sound.changeset(attrs)\n        |> Repo.insert()\n\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      sound = Repo.preload(sound, :tags)\n      assert [%{name: \"test_tag\"}] = sound.tags\n    end\n  end\n\n  describe \"queries\" do\n    test \"with_tags/1 preloads tags\", %{sound: sound, tag: tag} do\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      result = Sound.with_tags() |> Repo.all() |> Enum.find(&(&1.id == sound.id))\n      assert [%{name: \"test_tag\"}] = result.tags\n    end\n\n    test \"by_tag/2 filters sounds by tag name\", %{sound: sound, tag: tag} do\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      results = Sound.by_tag(\"test_tag\") |> Repo.all()\n      assert length(results) == 1\n      assert hd(results).id == sound.id\n    end\n\n    test \"list_files/0 returns all sounds with tags and settings\", %{sound: sound, tag: tag} do\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      result = Sounds.list_files() |> Enum.find(&(&1.id == sound.id))\n      assert result.id == sound.id\n      assert [%{name: \"test_tag\"}] = result.tags\n    end\n\n    test \"get_sound!/1 loads all associations\", %{sound: sound, tag: tag} do\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      result = Sounds.get_sound!(sound.id)\n      assert result.id == sound.id\n      assert [%{name: \"test_tag\"}] = result.tags\n    end\n  end\n\n  describe \"user sound settings\" do\n    test \"can set join sound without affecting leave sound\", %{user: user} do\n      # Create two sounds\n      {:ok, sound1} = insert_sound(user)\n      {:ok, sound2} = insert_sound(user)\n\n      # Set sound1 as both join and leave sound\n      {:ok, setting1} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound1.id,\n            is_join_sound: true,\n            is_leave_sound: true\n          }\n        )\n        |> Repo.insert()\n\n      # Set sound2 as join sound (should only unset sound1's join sound)\n      :ok = UserSoundSetting.clear_conflicting_settings(user.id, sound2.id, true, false)\n\n      {:ok, setting2} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound2.id,\n            is_join_sound: true,\n            is_leave_sound: false\n          }\n        )\n        |> Repo.insert()\n\n      # Reload settings to verify state\n      setting1 = Repo.get(UserSoundSetting, setting1.id)\n      setting2 = Repo.get(UserSoundSetting, setting2.id)\n\n      # Original sound should keep leave sound but lose join sound\n      assert setting1.is_join_sound == false\n      assert setting1.is_leave_sound == true\n\n      # New sound should be join sound only\n      assert setting2.is_join_sound == true\n      assert setting2.is_leave_sound == false\n    end\n\n    test \"can set leave sound without affecting join sound\", %{user: user} do\n      # Create two sounds\n      {:ok, sound1} = insert_sound(user)\n      {:ok, sound2} = insert_sound(user)\n\n      # Set sound1 as both join and leave sound\n      {:ok, setting1} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound1.id,\n            is_join_sound: true,\n            is_leave_sound: true\n          }\n        )\n        |> Repo.insert()\n\n      # Set sound2 as leave sound (should only unset sound1's leave sound)\n      :ok = UserSoundSetting.clear_conflicting_settings(user.id, sound2.id, false, true)\n\n      {:ok, setting2} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound2.id,\n            is_join_sound: false,\n            is_leave_sound: true\n          }\n        )\n        |> Repo.insert()\n\n      # Reload settings to verify state\n      setting1 = Repo.get(UserSoundSetting, setting1.id)\n      setting2 = Repo.get(UserSoundSetting, setting2.id)\n\n      # Original sound should keep join sound but lose leave sound\n      assert setting1.is_join_sound == true\n      assert setting1.is_leave_sound == false\n\n      # New sound should be leave sound only\n      assert setting2.is_join_sound == false\n      assert setting2.is_leave_sound == true\n    end\n\n    test \"can unset join/leave sounds independently\", %{user: user} do\n      {:ok, sound} = insert_sound(user)\n\n      {:ok, setting} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound.id,\n            is_join_sound: true,\n            is_leave_sound: true\n          }\n        )\n        |> Repo.insert()\n\n      # Unset join sound only\n      {:ok, updated_setting} =\n        UserSoundSetting.changeset(\n          setting,\n          %{is_join_sound: false}\n        )\n        |> Repo.update()\n\n      # Verify leave sound remains set\n      assert updated_setting.is_join_sound == false\n      assert updated_setting.is_leave_sound == true\n    end\n  end\n\n  describe \"fetch_sound_id/1\" do\n    test \"returns sound id when sound exists\", %{sound: sound} do\n      assert Sounds.fetch_sound_id(sound.filename) == {:ok, sound.id}\n    end\n\n    test \"returns :error when sound doesn't exist\" do\n      assert Sounds.fetch_sound_id(\"nonexistent.mp3\") == :error\n    end\n  end\n\n  describe \"fetch_filename_extension/1\" do\n    test \"returns the stored file extension\", %{sound: sound} do\n      assert Sounds.fetch_filename_extension(sound.id) == {:ok, \".mp3\"}\n    end\n\n    test \"returns :error when sound doesn't exist\" do\n      assert Sounds.fetch_filename_extension(-1) == :error\n    end\n  end\n\n  describe \"get_recent_uploads/1\" do\n    test \"returns recent uploads with default limit\", %{user: user} do\n      # Create multiple sounds\n      _sounds =\n        for _i <- 1..12 do\n          {:ok, sound} = insert_sound(user)\n          sound\n        end\n\n      results = Sounds.get_recent_uploads()\n\n      assert length(results) >= 10\n\n      {filename, username, timestamp} = hd(results)\n      assert is_binary(filename)\n      assert is_binary(username)\n      assert %NaiveDateTime{} = timestamp\n\n      user_results = Enum.filter(results, fn {_, uname, _} -> uname == user.username end)\n      assert user_results != []\n    end\n\n    test \"returns recent uploads with custom limit\", %{user: user} do\n      # Create 5 sounds\n      for _ <- 1..5, do: insert_sound(user)\n\n      results = Sounds.get_recent_uploads(limit: 3)\n      assert length(results) == 3\n    end\n\n    test \"returns empty list when no sounds exist\" do\n      # Delete all sounds\n      Repo.delete_all(Sound)\n\n      results = Sounds.get_recent_uploads()\n      assert results == []\n    end\n  end\n\n  describe \"update_sound/2\" do\n    test \"updates sound attributes\", %{sound: sound, tag: tag} do\n      # Preload tags to avoid association error\n      sound = Repo.preload(sound, :tags)\n\n      attrs = %{\n        description: \"Updated description\",\n        tags: [tag]\n      }\n\n      {:ok, updated_sound} = Sounds.update_sound(sound, attrs)\n\n      assert updated_sound.description == \"Updated description\"\n      assert length(updated_sound.tags) == 1\n      assert hd(updated_sound.tags).id == tag.id\n    end\n\n    test \"validates on update\", %{sound: sound} do\n      attrs = %{source_type: \"invalid\"}\n\n      {:error, changeset} = Sounds.update_sound(sound, attrs)\n      assert \"must be either 'local' or 'url'\" in errors_on(changeset).source_type\n    end\n  end\n\n  describe \"user join/leave sounds\" do\n    test \"get_user_join_sound/1 returns join sound filename\", %{user: user, sound: sound} do\n      # Create join sound setting\n      {:ok, _} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound.id,\n            is_join_sound: true,\n            is_leave_sound: false\n          }\n        )\n        |> Repo.insert()\n\n      assert Sounds.get_user_join_sound(user.id) == sound.filename\n    end\n\n    test \"get_user_join_sound/1 returns nil when no join sound\", %{user: user} do\n      assert Sounds.get_user_join_sound(user.id) == nil\n    end\n\n    test \"get_user_leave_sound/1 returns leave sound filename\", %{user: user, sound: sound} do\n      # Create leave sound setting\n      {:ok, _} =\n        UserSoundSetting.changeset(\n          %UserSoundSetting{},\n          %{\n            user_id: user.id,\n            sound_id: sound.id,\n            is_join_sound: false,\n            is_leave_sound: true\n          }\n        )\n        |> Repo.insert()\n\n      assert Sounds.get_user_leave_sound(user.id) == sound.filename\n    end\n\n    test \"get_user_leave_sound/1 returns nil when no leave sound\", %{user: user} do\n      assert Sounds.get_user_leave_sound(user.id) == nil\n    end\n  end\n\n  describe \"get_user_sound_preferences_by_discord_id/1\" do\n    test \"returns both join and leave sounds without assuming they share one row\", %{user: user} do\n      {:ok, join_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"join_#{System.unique_integer([:positive])}.mp3\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      {:ok, leave_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"leave_#{System.unique_integer([:positive])}.mp3\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      %UserSoundSetting{}\n      |> UserSoundSetting.changeset(%{\n        user_id: user.id,\n        sound_id: join_sound.id,\n        is_join_sound: true,\n        is_leave_sound: false\n      })\n      |> Repo.insert!()\n\n      %UserSoundSetting{}\n      |> UserSoundSetting.changeset(%{\n        user_id: user.id,\n        sound_id: leave_sound.id,\n        is_join_sound: false,\n        is_leave_sound: true\n      })\n      |> Repo.insert!()\n\n      user_id = user.id\n\n      assert %{user_id: ^user_id, join_sound: join_filename, leave_sound: leave_filename} =\n               Sounds.get_user_sound_preferences_by_discord_id(user.discord_id)\n\n      assert join_filename == join_sound.filename\n      assert leave_filename == leave_sound.filename\n    end\n\n    test \"returns user preferences with nil sounds when no join/leave sounds\", %{user: user} do\n      user_id = user.id\n\n      assert %{user_id: ^user_id, join_sound: nil, leave_sound: nil} =\n               Sounds.get_user_sound_preferences_by_discord_id(user.discord_id)\n    end\n\n    test \"returns nil when user doesn't exist\" do\n      assert Sounds.get_user_sound_preferences_by_discord_id(\"nonexistent_discord_id\") == nil\n    end\n  end\n\n  describe \"changeset with tags\" do\n    test \"associates tags when provided in attrs\", %{user: user, tag: tag} do\n      attrs = %{\n        filename: \"tagged_sound.mp3\",\n        source_type: \"local\",\n        user_id: user.id,\n        tags: [tag]\n      }\n\n      changeset = Sound.changeset(%Sound{}, attrs)\n      assert changeset.valid?\n\n      {:ok, sound} = Repo.insert(changeset)\n      sound = Repo.preload(sound, :tags)\n\n      assert length(sound.tags) == 1\n      assert hd(sound.tags).id == tag.id\n    end\n\n    test \"handles empty tags list\", %{user: user} do\n      attrs = %{\n        filename: \"no_tags_sound.mp3\",\n        source_type: \"local\",\n        user_id: user.id,\n        tags: []\n      }\n\n      changeset = Sound.changeset(%Sound{}, attrs)\n      assert changeset.valid?\n\n      {:ok, sound} = Repo.insert(changeset)\n      sound = Repo.preload(sound, :tags)\n\n      assert sound.tags == []\n    end\n  end\n\n  describe \"repo persistence\" do\n    test \"can rename sound\", %{sound: sound} do\n      {:ok, updated_sound} =\n        Sound.changeset(sound, %{filename: \"renamed_sound.mp3\"})\n        |> Repo.update()\n\n      assert updated_sound.filename == \"renamed_sound.mp3\"\n      assert updated_sound.id == sound.id\n    end\n\n    test \"owner can delete sound\", %{sound: sound} do\n      assert {:ok, _} = Repo.delete(sound)\n      refute Repo.get(Sound, sound.id)\n    end\n  end\n\n  # Helper functions\n  defp insert_user do\n    {:ok, user} =\n      %Soundboard.Accounts.User{}\n      |> User.changeset(%{\n        username: \"test_user_#{System.unique_integer()}\",\n        discord_id: \"123456_#{System.unique_integer()}\",\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\n\n  defp insert_sound(user) do\n    %Sound{}\n    |> Sound.changeset(%{\n      filename: \"test_sound_#{System.unique_integer()}.mp3\",\n      source_type: \"local\",\n      user_id: user.id\n    })\n    |> Repo.insert()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sounds/management_test.exs",
    "content": "defmodule Soundboard.Sounds.ManagementTest do\n  use Soundboard.DataCase\n\n  import Mock\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound, UserSoundSetting}\n  alias Soundboard.Sounds.Management\n\n  setup do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"mgmt_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    %{user: user}\n  end\n\n  test \"delete_sound/2 removes local file and record\", %{user: user} do\n    filename = \"delete_#{System.unique_integer([:positive])}.mp3\"\n    sound = insert_local_sound(user, filename)\n\n    local_path = Path.join(uploads_dir(), filename)\n    File.write!(local_path, \"audio\")\n    on_exit(fn -> File.rm(local_path) end)\n    assert File.exists?(local_path)\n\n    with_mock Soundboard.AudioPlayer, invalidate_cache: fn ^filename -> :ok end do\n      assert :ok = Management.delete_sound(sound, user.id)\n      assert_called(Soundboard.AudioPlayer.invalidate_cache(filename))\n    end\n\n    refute File.exists?(local_path)\n    assert Repo.get(Sound, sound.id) == nil\n  end\n\n  test \"update_sound/3 renames local file and upserts user settings\", %{user: user} do\n    filename = \"old_#{System.unique_integer([:positive])}.mp3\"\n    sound = insert_local_sound(user, filename)\n\n    old_path = Path.join(uploads_dir(), filename)\n    File.write!(old_path, \"audio\")\n    on_exit(fn -> File.rm(old_path) end)\n\n    params = %{\n      \"filename\" => \"renamed_#{System.unique_integer([:positive])}\",\n      \"source_type\" => \"local\",\n      \"url\" => nil,\n      \"volume\" => \"80\",\n      \"is_join_sound\" => \"true\",\n      \"is_leave_sound\" => \"false\"\n    }\n\n    new_filename = params[\"filename\"] <> \".mp3\"\n\n    with_mock Soundboard.AudioPlayer,\n      invalidate_cache: fn cache_key when cache_key in [filename, new_filename] -> :ok end do\n      assert {:ok, updated_sound} = Management.update_sound(sound, user.id, params)\n\n      assert_called(Soundboard.AudioPlayer.invalidate_cache(filename))\n      assert_called(Soundboard.AudioPlayer.invalidate_cache(new_filename))\n\n      new_path = Path.join(uploads_dir(), new_filename)\n      on_exit(fn -> File.rm(new_path) end)\n\n      assert updated_sound.filename == new_filename\n      assert File.exists?(new_path)\n      refute File.exists?(old_path)\n\n      setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: updated_sound.id)\n      assert setting.is_join_sound\n      refute setting.is_leave_sound\n    end\n  end\n\n  test \"update_sound/3 keeps sound metadata collaborative while preserving uploader ownership\", %{\n    user: user\n  } do\n    filename = \"shared_#{System.unique_integer([:positive])}.mp3\"\n    sound = insert_local_sound(user, filename)\n\n    old_path = Path.join(uploads_dir(), filename)\n    File.write!(old_path, \"audio\")\n    on_exit(fn -> File.rm(old_path) end)\n\n    {:ok, editor} =\n      %User{}\n      |> User.changeset(%{\n        username: \"editor_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    params = %{\n      \"filename\" => \"edited_by_other_#{System.unique_integer([:positive])}\",\n      \"source_type\" => \"local\",\n      \"url\" => nil,\n      \"volume\" => \"65\",\n      \"is_join_sound\" => \"true\",\n      \"is_leave_sound\" => \"false\"\n    }\n\n    assert {:ok, updated_sound} = Management.update_sound(sound, editor.id, params)\n\n    new_filename = params[\"filename\"] <> \".mp3\"\n    new_path = Path.join(uploads_dir(), new_filename)\n    on_exit(fn -> File.rm(new_path) end)\n\n    assert updated_sound.filename == new_filename\n    assert updated_sound.user_id == user.id\n    assert File.exists?(new_path)\n    refute File.exists?(old_path)\n\n    setting = Repo.get_by!(UserSoundSetting, user_id: editor.id, sound_id: updated_sound.id)\n    assert setting.is_join_sound\n    refute setting.is_leave_sound\n  end\n\n  test \"delete_sound/2 stays owner-only even when metadata edits are collaborative\", %{user: user} do\n    sound = insert_local_sound(user, \"locked_#{System.unique_integer([:positive])}.mp3\")\n\n    {:ok, intruder} =\n      %User{}\n      |> User.changeset(%{\n        username: \"delete_intruder_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    assert {:error, :forbidden} = Management.delete_sound(sound, intruder.id)\n    assert Repo.get!(Sound, sound.id)\n  end\n\n  defp insert_local_sound(user, filename) do\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: filename,\n        source_type: \"local\",\n        user_id: user.id,\n        volume: 1.0\n      })\n      |> Repo.insert()\n\n    sound\n  end\n\n  defp uploads_dir do\n    Soundboard.UploadsPath.dir()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sounds/sound_settings_test.exs",
    "content": "defmodule Soundboard.Sounds.SoundSettingsTest do\n  @moduledoc \"\"\"\n  The SoundSettingsTest module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.{Accounts.User, Repo, Sound, UserSoundSetting}\n  import Ecto.Changeset\n\n  setup do\n    user = insert_user()\n    {:ok, sound} = insert_sound(user)\n    %{user: user, sound: sound}\n  end\n\n  describe \"changeset validation\" do\n    test \"requires user_id and sound_id\" do\n      changeset = UserSoundSetting.changeset(%UserSoundSetting{}, %{})\n\n      assert errors_on(changeset) == %{\n               user_id: [\"can't be blank\"],\n               sound_id: [\"can't be blank\"]\n             }\n    end\n\n    test \"defaults join and leave sounds to false\", %{user: user, sound: sound} do\n      attrs = %{\n        user_id: user.id,\n        sound_id: sound.id\n      }\n\n      changeset = UserSoundSetting.changeset(%UserSoundSetting{}, attrs)\n      assert get_field(changeset, :is_join_sound) == false\n      assert get_field(changeset, :is_leave_sound) == false\n    end\n\n    test \"accepts join and leave sound settings\", %{user: user, sound: sound} do\n      attrs = %{\n        user_id: user.id,\n        sound_id: sound.id,\n        is_join_sound: true,\n        is_leave_sound: false\n      }\n\n      changeset = UserSoundSetting.changeset(%UserSoundSetting{}, attrs)\n      assert get_field(changeset, :is_join_sound) == true\n      assert get_field(changeset, :is_leave_sound) == false\n    end\n\n    test \"building a changeset does not mutate existing join settings\", %{\n      user: user,\n      sound: sound\n    } do\n      other_sound = insert_sound!(user, \"other_#{System.unique_integer([:positive])}.mp3\")\n\n      %UserSoundSetting{}\n      |> UserSoundSetting.changeset(%{\n        user_id: user.id,\n        sound_id: other_sound.id,\n        is_join_sound: true\n      })\n      |> Repo.insert!()\n\n      _changeset =\n        UserSoundSetting.changeset(%UserSoundSetting{}, %{\n          user_id: user.id,\n          sound_id: sound.id,\n          is_join_sound: true\n        })\n\n      existing_setting =\n        Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: other_sound.id)\n\n      assert existing_setting.is_join_sound\n    end\n  end\n\n  describe \"conflicting setting cleanup\" do\n    test \"clear_conflicting_settings/4 clears other join and leave flags\", %{\n      user: user,\n      sound: sound\n    } do\n      other_sound = insert_sound!(user, \"other_#{System.unique_integer([:positive])}.mp3\")\n\n      join_setting =\n        %UserSoundSetting{}\n        |> UserSoundSetting.changeset(%{\n          user_id: user.id,\n          sound_id: other_sound.id,\n          is_join_sound: true\n        })\n        |> Repo.insert!()\n\n      leave_setting =\n        %UserSoundSetting{}\n        |> UserSoundSetting.changeset(%{\n          user_id: user.id,\n          sound_id: other_sound.id,\n          is_leave_sound: true\n        })\n        |> Repo.insert!()\n\n      assert :ok = UserSoundSetting.clear_conflicting_settings(user.id, sound.id, true, true)\n\n      refute Repo.get!(UserSoundSetting, join_setting.id).is_join_sound\n      refute Repo.get!(UserSoundSetting, leave_setting.id).is_leave_sound\n    end\n  end\n\n  describe \"unique constraints\" do\n    test \"enforces unique join sound per user\", %{user: user, sound: sound} do\n      # First insert succeeds\n      {:ok, _} =\n        %UserSoundSetting{\n          user_id: user.id,\n          sound_id: sound.id,\n          is_join_sound: true\n        }\n        |> Repo.insert()\n\n      # Second insert should fail with constraint error\n      changeset =\n        %UserSoundSetting{}\n        |> UserSoundSetting.changeset(%{\n          user_id: user.id,\n          sound_id: sound.id,\n          is_join_sound: true\n        })\n        |> unique_constraint(:user_id,\n          name: \"user_sound_settings_user_id_is_join_sound_index\",\n          message: \"already has a join sound\"\n        )\n\n      {:error, changeset} = Repo.insert(changeset)\n      assert %{user_id: [\"already has a join sound\"]} = errors_on(changeset)\n    end\n\n    test \"enforces unique leave sound per user\", %{user: user, sound: sound} do\n      # First insert succeeds\n      {:ok, _} =\n        %UserSoundSetting{\n          user_id: user.id,\n          sound_id: sound.id,\n          is_leave_sound: true\n        }\n        |> Repo.insert()\n\n      # Second insert should fail with constraint error\n      changeset =\n        %UserSoundSetting{}\n        |> UserSoundSetting.changeset(%{\n          user_id: user.id,\n          sound_id: sound.id,\n          is_leave_sound: true\n        })\n        |> unique_constraint(:user_id,\n          name: \"user_sound_settings_user_id_is_leave_sound_index\",\n          message: \"already has a leave sound\"\n        )\n\n      {:error, changeset} = Repo.insert(changeset)\n      assert %{user_id: [\"already has a leave sound\"]} = errors_on(changeset)\n    end\n  end\n\n  # Helper functions\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"test_user\",\n        discord_id: \"123456\",\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\n\n  defp insert_sound(user) do\n    insert_sound!(user, \"test_sound#{System.unique_integer()}.mp3\")\n    |> then(&{:ok, &1})\n  end\n\n  defp insert_sound!(user, filename) do\n    %Sound{}\n    |> Sound.changeset(%{\n      filename: filename,\n      source_type: \"local\",\n      user_id: user.id\n    })\n    |> Repo.insert!()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sounds/tags_test.exs",
    "content": "defmodule Soundboard.Sounds.TagsTest do\n  use Soundboard.DataCase\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound, Tag}\n  alias Soundboard.Sounds.Tags\n\n  setup do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"tags_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"sound_#{System.unique_integer([:positive])}.mp3\",\n        source_type: \"local\",\n        user_id: user.id,\n        volume: 1.0\n      })\n      |> Repo.insert()\n\n    %{user: user, sound: sound}\n  end\n\n  test \"search/1 delegates to tag search\", %{sound: sound} do\n    alpha = insert_tag!(\"alpha\")\n    beta = insert_tag!(\"beta\")\n    {:ok, _updated_sound} = Tags.update_sound_tags(sound, [alpha, beta])\n\n    assert [result] = Tags.search(\"alp\")\n    assert result.name == \"alpha\"\n  end\n\n  test \"all_for_sounds/1 deduplicates and sorts tags\" do\n    alpha = %Tag{id: 2, name: \"alpha\"}\n    beta = %Tag{id: 1, name: \"beta\"}\n\n    sounds = [\n      %{tags: [beta, alpha]},\n      %{tags: [alpha]}\n    ]\n\n    assert Enum.map(Tags.all_for_sounds(sounds), & &1.name) == [\"alpha\", \"beta\"]\n  end\n\n  test \"count_sounds_with_tag/2 and tag_selected?/2 work on associations\" do\n    alpha = %Tag{id: 1, name: \"alpha\"}\n    beta = %Tag{id: 2, name: \"beta\"}\n\n    sounds = [\n      %{tags: [alpha]},\n      %{tags: [alpha, beta]},\n      %{tags: []}\n    ]\n\n    assert Tags.count_sounds_with_tag(sounds, alpha) == 2\n    assert Tags.count_sounds_with_tag(sounds, beta) == 1\n    assert Tags.tag_selected?(alpha, [beta, alpha])\n    refute Tags.tag_selected?(%Tag{id: 3, name: \"gamma\"}, [beta, alpha])\n  end\n\n  test \"resolve_many/1 normalizes, deduplicates, and ignores nil-like values\" do\n    assert {:ok, [alpha, beta]} = Tags.resolve_many([\" Alpha \", \"beta\", \"alpha\", nil])\n    assert alpha.name == \"alpha\"\n    assert beta.name == \"beta\"\n  end\n\n  test \"resolve/1 rejects blank tag names\" do\n    assert {:error, changeset} = Tags.resolve(\"   \")\n    assert \"can't be blank\" in errors_on(changeset).tags\n  end\n\n  test \"resolve/1 returns existing tag structs and non-binary values safely\" do\n    tag = insert_tag!(\"existing\")\n\n    assert {:ok, ^tag} = Tags.resolve(tag)\n    assert {:ok, nil} = Tags.resolve(:skip)\n  end\n\n  test \"find_or_create/1 reuses normalized existing tags\" do\n    tag = insert_tag!(\"mixed\")\n\n    assert {:ok, resolved} = Tags.find_or_create(\"  MIXED  \")\n    assert resolved.id == tag.id\n  end\n\n  test \"list_for_sound/1 returns tags for matching sounds and [] for missing sounds\", %{\n    sound: sound\n  } do\n    alpha = insert_tag!(\"listed\")\n    {:ok, _updated_sound} = Tags.update_sound_tags(sound, [alpha])\n\n    assert [%Tag{name: \"listed\"}] = Tags.list_for_sound(sound.filename)\n    assert [] = Tags.list_for_sound(\"missing.mp3\")\n  end\n\n  defp insert_tag!(name) do\n    %Tag{}\n    |> Tag.changeset(%{name: name})\n    |> Repo.insert!()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/sounds/uploads_test.exs",
    "content": "defmodule Soundboard.Sounds.UploadsTest do\n  use Soundboard.DataCase\n\n  import Soundboard.DataCase, only: [errors_on: 1]\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound, UserSoundSetting}\n  alias Soundboard.Sounds.Uploads\n  alias Soundboard.Sounds.Uploads.CreateRequest\n\n  setup do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"upload_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    %{user: user}\n  end\n\n  describe \"validate/1\" do\n    test \"validates URL uploads when enough input is present\", %{user: user} do\n      assert {:ok, _params} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: \"validated_url\",\n                 url: \"https://example.com/sound.mp3\"\n               })\n               |> Uploads.validate()\n    end\n\n    test \"requires a url for url uploads\", %{user: user} do\n      assert {:error, changeset} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: \"validated_url\"\n               })\n               |> Uploads.validate()\n\n      assert \"can't be blank\" in errors_on(changeset).url\n    end\n\n    test \"rejects duplicate local filenames before copying\", %{user: user} do\n      {:ok, _existing} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"duplicate_name.mp3\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      assert {:error, changeset} =\n               user\n               |> request(%{\n                 source_type: \"local\",\n                 name: \"duplicate_name\",\n                 upload: %{filename: \"dup.mp3\"}\n               })\n               |> Uploads.validate()\n\n      assert \"has already been taken\" in errors_on(changeset).filename\n    end\n\n    test \"requires a local file selection for local uploads\", %{user: user} do\n      assert {:error, changeset} =\n               user\n               |> request(%{\n                 source_type: \"local\",\n                 name: \"missing_file\"\n               })\n               |> Uploads.validate()\n\n      assert \"Please select a file\" in errors_on(changeset).file\n    end\n  end\n\n  describe \"create/1\" do\n    test \"creates url sound with tags and settings\", %{user: user} do\n      name = \"upload_url_#{System.unique_integer([:positive])}\"\n\n      assert {:ok, sound} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: name,\n                 url: \"https://example.com/sound.mp3\",\n                 tags: [\"alpha\", \"beta\"],\n                 volume: \"45\",\n                 is_join_sound: \"true\"\n               })\n               |> Uploads.create()\n\n      assert sound.filename == \"#{name}.mp3\"\n      assert sound.source_type == \"url\"\n      assert_in_delta sound.volume, 0.45, 0.0001\n\n      sound = Repo.preload(sound, :tags)\n      assert Enum.sort(Enum.map(sound.tags, & &1.name)) == [\"alpha\", \"beta\"]\n\n      setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: sound.id)\n      assert setting.is_join_sound\n      refute setting.is_leave_sound\n    end\n\n    test \"publishes canonical soundboard events after create\", %{user: user} do\n      Soundboard.PubSubTopics.subscribe_files()\n      Soundboard.PubSubTopics.subscribe_stats()\n\n      name = \"upload_events_#{System.unique_integer([:positive])}\"\n\n      assert {:ok, _sound} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: name,\n                 url: \"https://example.com/events.mp3\"\n               })\n               |> Uploads.create()\n\n      assert_receive {:files_updated}\n      assert_receive {:stats_updated}\n    end\n\n    test \"copies local file and persists sound\", %{user: user} do\n      name = \"upload_local_#{System.unique_integer([:positive])}\"\n      tmp_path = Path.join(System.tmp_dir!(), \"#{System.unique_integer([:positive])}-local.wav\")\n      File.write!(tmp_path, \"audio\")\n\n      on_exit(fn -> File.rm(tmp_path) end)\n\n      assert {:ok, sound} =\n               user\n               |> request(%{\n                 source_type: \"local\",\n                 name: name,\n                 upload: %{path: tmp_path, filename: \"local.wav\"}\n               })\n               |> Uploads.create()\n\n      copied_path = Path.join(uploads_dir(), sound.filename)\n      assert File.exists?(copied_path)\n\n      on_exit(fn -> File.rm(copied_path) end)\n    end\n\n    test \"clears previous join setting when creating a new join sound\", %{user: user} do\n      first_name = \"first_join_#{System.unique_integer([:positive])}\"\n      second_name = \"second_join_#{System.unique_integer([:positive])}\"\n\n      assert {:ok, first_sound} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: first_name,\n                 url: \"https://example.com/first.mp3\",\n                 is_join_sound: true\n               })\n               |> Uploads.create()\n\n      assert {:ok, second_sound} =\n               user\n               |> request(%{\n                 source_type: \"url\",\n                 name: second_name,\n                 url: \"https://example.com/second.mp3\",\n                 is_join_sound: true\n               })\n               |> Uploads.create()\n\n      first_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: first_sound.id)\n      second_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: second_sound.id)\n\n      refute first_setting.is_join_sound\n      assert second_setting.is_join_sound\n    end\n\n    test \"returns error when local file is missing\", %{user: user} do\n      assert {:error, changeset} =\n               user\n               |> request(%{\n                 source_type: \"local\",\n                 name: \"missing_file\"\n               })\n               |> Uploads.create()\n\n      assert \"Please select a file\" in errors_on(changeset).file\n    end\n\n    test \"returns duplicate filename validation for local upload\", %{user: user} do\n      {:ok, _existing} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"duplicate_name.mp3\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      tmp_path = Path.join(System.tmp_dir!(), \"#{System.unique_integer([:positive])}-dup.mp3\")\n      File.write!(tmp_path, \"audio\")\n      on_exit(fn -> File.rm(tmp_path) end)\n\n      assert {:error, changeset} =\n               user\n               |> request(%{\n                 source_type: \"local\",\n                 name: \"duplicate_name\",\n                 upload: %{path: tmp_path, filename: \"dup.mp3\"}\n               })\n               |> Uploads.create()\n\n      assert \"has already been taken\" in errors_on(changeset).filename\n    end\n  end\n\n  defp request(user, attrs) do\n    CreateRequest.new(user, attrs)\n  end\n\n  defp uploads_dir do\n    Soundboard.UploadsPath.dir()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/stats_test.exs",
    "content": "defmodule Soundboard.StatsTest do\n  @moduledoc \"\"\"\n  Test for the Stats module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.{Accounts.User, PubSubTopics, Sound, Stats, Stats.Play}\n\n  describe \"stats\" do\n    setup do\n      user = insert_user()\n      sound = insert_sound(user)\n      %{user: user, sound: sound}\n    end\n\n    test \"track_play creates a play record\", %{user: user, sound: sound} do\n      assert {:ok, play} = Stats.track_play(sound.filename, user.id)\n      assert play.played_filename == sound.filename\n      assert play.sound_id == sound.id\n      assert play.user_id == user.id\n    end\n\n    test \"get_top_sounds preserves the played filename snapshot after a sound is renamed\", %{\n      user: user,\n      sound: sound\n    } do\n      today = Date.utc_today()\n      original_filename = sound.filename\n      Stats.track_play(original_filename, user.id)\n\n      {:ok, _renamed_sound} =\n        sound\n        |> Sound.changeset(%{filename: \"renamed_#{System.unique_integer()}.mp3\"})\n        |> Repo.update()\n\n      results = Stats.get_top_sounds(today, today)\n\n      sound_plays =\n        Enum.find(results, fn {filename, _count} -> filename == original_filename end)\n\n      assert sound_plays != nil\n      assert {^original_filename, count} = sound_plays\n      assert count >= 1\n    end\n\n    test \"get_recent_plays falls back to the stored sound name when a sound is deleted\", %{\n      user: user,\n      sound: sound\n    } do\n      Stats.track_play(sound.filename, user.id)\n      Repo.delete!(sound)\n\n      assert [{_id, filename, username, _timestamp}] = Stats.get_recent_plays(limit: 1)\n      assert filename == sound.filename\n      assert username == user.username\n    end\n\n    test \"play changeset requires sound_id\" do\n      changeset = Play.changeset(%Play{}, %{played_filename: \"beep.mp3\", user_id: 123})\n\n      assert %{sound_id: [\"can't be blank\"]} = errors_on(changeset)\n    end\n\n    test \"get_top_users returns users ordered by play count\", %{user: user, sound: sound} do\n      today = Date.utc_today()\n      Enum.each(1..3, fn _ -> Stats.track_play(sound.filename, user.id) end)\n\n      results = Stats.get_top_users(today, today)\n      user_plays = Enum.find(results, fn {username, _count} -> username == user.username end)\n\n      assert user_plays != nil\n      assert {_username, count} = user_plays\n      assert count >= 3\n    end\n\n    test \"get_top_sounds returns sounds ordered by play count\", %{user: user, sound: sound} do\n      today = Date.utc_today()\n      Enum.each(1..3, fn _ -> Stats.track_play(sound.filename, user.id) end)\n\n      results = Stats.get_top_sounds(today, today)\n      sound_plays = Enum.find(results, fn {filename, _count} -> filename == sound.filename end)\n\n      assert sound_plays != nil\n      assert {_filename, count} = sound_plays\n      assert count >= 3\n    end\n\n    test \"get_recent_plays returns most recent plays\", %{user: user, sound: sound} do\n      Stats.track_play(sound.filename, user.id)\n\n      assert [{_id, filename, username, _timestamp}] = Stats.get_recent_plays(limit: 1)\n      assert filename == sound.filename\n      assert username == user.username\n    end\n\n    test \"reset_weekly_stats deletes old plays\", %{user: user, sound: sound} do\n      old_date =\n        NaiveDateTime.utc_now()\n        |> NaiveDateTime.add(-8, :day)\n        |> NaiveDateTime.truncate(:second)\n\n      play = %Play{\n        played_filename: sound.filename,\n        sound_id: sound.id,\n        user_id: user.id,\n        inserted_at: old_date\n      }\n\n      Repo.insert!(play)\n\n      Stats.track_play(sound.filename, user.id)\n\n      initial_count = length(Repo.all(Play))\n      Stats.reset_weekly_stats()\n      final_count = length(Repo.all(Play))\n\n      # Should have at least one less play after reset\n      assert final_count < initial_count\n    end\n\n    test \"track_play broadcasts stats only after a successful insert\", %{sound: sound} do\n      PubSubTopics.subscribe_stats()\n\n      assert {:error, changeset} = Stats.track_play(sound.filename, nil)\n      assert \"can't be blank\" in errors_on(changeset).user_id\n      refute_receive {:stats_updated}\n\n      assert {:ok, _play} = Stats.track_play(sound.filename, sound.user_id)\n      assert_receive {:stats_updated}\n      refute_receive {:stats_updated}\n    end\n\n    test \"broadcast_stats_update sends update message\" do\n      PubSubTopics.subscribe_stats()\n\n      Stats.broadcast_stats_update()\n\n      assert_receive {:stats_updated}\n      refute_receive {:stats_updated}\n    end\n  end\n\n  # Helper functions\n  defp insert_sound(user) do\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test_sound#{System.unique_integer()}.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      })\n      |> Repo.insert()\n\n    sound\n  end\n\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser#{System.unique_integer()}\",\n        discord_id: \"123456789\",\n        avatar: \"test_avatar.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\nend\n"
  },
  {
    "path": "test/soundboard/tags/tag_test.exs",
    "content": "defmodule Soundboard.Tags.TagTest do\n  @moduledoc \"\"\"\n  Tests the Tag module.\n  \"\"\"\n  use Soundboard.DataCase\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Repo, Sound, Tag}\n\n  import Ecto.Changeset\n\n  describe \"tag validation\" do\n    test \"requires name\" do\n      changeset = Tag.changeset(%Tag{}, %{})\n      assert %{name: [\"can't be blank\"]} = errors_on(changeset)\n    end\n\n    test \"enforces unique names\" do\n      {:ok, _tag} =\n        %Tag{name: \"test\"}\n        |> Tag.changeset(%{})\n        |> unique_constraint(:name)\n        |> Repo.insert()\n\n      {:error, changeset} =\n        %Tag{name: \"test\"}\n        |> Tag.changeset(%{})\n        |> unique_constraint(:name)\n        |> Repo.insert()\n\n      assert %{name: [\"has already been taken\"]} = errors_on(changeset)\n    end\n  end\n\n  describe \"tag management\" do\n    setup do\n      user = insert_user()\n      {:ok, sound} = insert_sound(user)\n      {:ok, tag} = %Tag{name: \"test_tag\"} |> Tag.changeset(%{}) |> Repo.insert()\n      %{sound: sound, tag: tag}\n    end\n\n    test \"associates tags with sounds\", %{sound: sound, tag: tag} do\n      now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)\n\n      # Insert directly into join table with timestamps\n      Repo.query!(\n        \"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)\",\n        [sound.id, tag.id, now, now]\n      )\n\n      updated_sound = Repo.preload(sound, :tags)\n      assert [%{name: \"test_tag\"}] = updated_sound.tags\n    end\n  end\n\n  describe \"tag search\" do\n    setup do\n      {:ok, _} = Repo.insert(%Tag{name: \"test\"})\n      {:ok, _} = Repo.insert(%Tag{name: \"testing\"})\n      {:ok, _} = Repo.insert(%Tag{name: \"other\"})\n      :ok\n    end\n\n    test \"finds tags by partial name match\" do\n      results = Tag.search(\"test\") |> Repo.all()\n      assert length(results) == 2\n      assert Enum.map(results, & &1.name) |> Enum.sort() == [\"test\", \"testing\"]\n    end\n\n    test \"search is case insensitive\" do\n      results = Tag.search(\"TEST\") |> Repo.all()\n      assert length(results) == 2\n      assert Enum.map(results, & &1.name) |> Enum.sort() == [\"test\", \"testing\"]\n    end\n  end\n\n  # Helper functions\n  defp insert_user do\n    {:ok, user} =\n      %Soundboard.Accounts.User{}\n      |> User.changeset(%{\n        username: \"test_user\",\n        discord_id: \"123456\",\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\n\n  defp insert_sound(user) do\n    %Sound{}\n    |> Sound.changeset(%{\n      filename: \"test_sound.mp3\",\n      source_type: \"local\",\n      user_id: user.id\n    })\n    |> Repo.insert()\n  end\nend\n"
  },
  {
    "path": "test/soundboard/uploads_path_test.exs",
    "content": "defmodule Soundboard.UploadsPathTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.UploadsPath\n\n  setup do\n    original_uploads_dir = Application.get_env(:soundboard, :uploads_dir)\n\n    on_exit(fn ->\n      if is_nil(original_uploads_dir) do\n        Application.delete_env(:soundboard, :uploads_dir)\n      else\n        Application.put_env(:soundboard, :uploads_dir, original_uploads_dir)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"dir/0 expands relative configured paths inside the application dir\" do\n    Application.put_env(:soundboard, :uploads_dir, \"tmp/test_uploads\")\n\n    assert UploadsPath.dir() == Application.app_dir(:soundboard, \"tmp/test_uploads\")\n  end\n\n  test \"dir/0 preserves absolute configured paths\" do\n    absolute = Path.join(System.tmp_dir!(), \"soundboard-uploads\")\n    Application.put_env(:soundboard, :uploads_dir, absolute)\n\n    assert UploadsPath.dir() == absolute\n  end\n\n  test \"file_path/1 and joined_path/1 build paths relative to uploads dir\" do\n    base = Path.join(System.tmp_dir!(), \"soundboard-uploads-paths\")\n    Application.put_env(:soundboard, :uploads_dir, base)\n\n    assert UploadsPath.file_path(\"beep.mp3\") == Path.join(base, \"beep.mp3\")\n    assert UploadsPath.joined_path(\"nested/beep.mp3\") == Path.join(base, \"nested/beep.mp3\")\n\n    assert UploadsPath.joined_path([\"nested\", \"beep.mp3\"]) ==\n             Path.join([base, \"nested\", \"beep.mp3\"])\n  end\n\n  test \"safe_joined_path/1 allows in-directory paths and rejects traversal\" do\n    base = Path.join(System.tmp_dir!(), \"soundboard-safe-uploads\")\n    Application.put_env(:soundboard, :uploads_dir, base)\n\n    assert {:ok, ^base} = UploadsPath.safe_joined_path([\".\"])\n\n    assert {:ok, safe_path} = UploadsPath.safe_joined_path([\"nested\", \"clip.mp3\"])\n    assert safe_path == Path.join([base, \"nested\", \"clip.mp3\"]) |> Path.expand()\n\n    assert :error = UploadsPath.safe_joined_path([\"..\", \"escape.mp3\"])\n    assert :error = UploadsPath.safe_joined_path(\"../escape.mp3\")\n  end\nend\n"
  },
  {
    "path": "test/soundboard/volume_test.exs",
    "content": "defmodule Soundboard.VolumeTest do\n  use ExUnit.Case, async: true\n  alias Soundboard.Volume\n\n  describe \"normalize_percent/2\" do\n    test \"handles integers\" do\n      assert Volume.normalize_percent(75, 100) == 75\n      assert Volume.normalize_percent(-5, 50) == 0\n      assert Volume.normalize_percent(150, 50) == 150\n    end\n\n    test \"handles strings and fallbacks\" do\n      assert Volume.normalize_percent(\"42\", 100) == 42\n      assert Volume.normalize_percent(\" 84.5 \", 10) == 85\n      assert Volume.normalize_percent(\"garbage\", 30) == 30\n    end\n  end\n\n  describe \"percent_to_decimal/2\" do\n    test \"converts to decimal with fallback\" do\n      assert Volume.percent_to_decimal(\"50\", 100) == 0.5\n      assert Volume.percent_to_decimal(nil, 80) == 0.8\n      assert Volume.percent_to_decimal(\"110\", 100) == 1.1\n      assert Volume.percent_to_decimal(\"150\", 100) == 1.5\n      assert Volume.percent_to_decimal(\"200\", 25) == 1.5\n    end\n  end\n\n  describe \"decimal_to_percent/1\" do\n    test \"handles nil and bounds\" do\n      assert Volume.decimal_to_percent(nil) == 100\n      assert Volume.decimal_to_percent(0.0625) == 6\n      assert Volume.decimal_to_percent(0.64) == 64\n      assert Volume.decimal_to_percent(1.1) == 110\n      assert Volume.decimal_to_percent(1.4) == 140\n      assert Volume.decimal_to_percent(1.6) == 150\n      assert Volume.decimal_to_percent(-0.2) == 0\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_test.exs",
    "content": "defmodule SoundboardTest do\n  use ExUnit.Case, async: true\n  doctest Soundboard\n\n  describe \"module documentation\" do\n    test \"module exists\" do\n      assert {:module, Soundboard} == Code.ensure_loaded(Soundboard)\n      assert function_exported?(Soundboard, :__info__, 1)\n    end\n\n    test \"has module documentation\" do\n      moduledoc = Code.fetch_docs(Soundboard)\n      assert match?({:docs_v1, _, :elixir, _, %{\"en\" => _}, _, _}, moduledoc)\n\n      {:docs_v1, _, :elixir, _, %{\"en\" => doc}, _, _} = moduledoc\n      assert doc =~ \"Soundboard keeps the contexts\"\n      assert doc =~ \"business logic\"\n    end\n\n    test \"returns application name\" do\n      assert Soundboard.app_name() == :soundboard\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/audio_player_test.exs",
    "content": "defmodule Soundboard.AudioPlayerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.AudioPlayer\n  alias Soundboard.AudioPlayer.State\n  alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoicePresence}\n  alias Soundboard.Discord.Voice\n\n  setup do\n    original_state = :sys.get_state(AudioPlayer)\n\n    on_exit(fn ->\n      :sys.replace_state(AudioPlayer, fn _ -> original_state end)\n    end)\n\n    :ok\n  end\n\n  test \"continues voice maintenance when playback status is unavailable\" do\n    test_pid = self()\n\n    :sys.replace_state(AudioPlayer, fn _ ->\n      %State{\n        voice_channel: {\"guild-1\", \"channel-1\"},\n        current_playback: nil,\n        pending_request: nil,\n        interrupting: false,\n        interrupt_watchdog_ref: nil,\n        interrupt_watchdog_attempt: 0\n      }\n    end)\n\n    with_mock Voice,\n      channel_id: fn \"guild-1\" -> \"channel-1\" end,\n      ready?: fn \"guild-1\" -> false end,\n      playing?: fn \"guild-1\" -> raise \"playback status unavailable\" end,\n      leave_channel: fn \"guild-1\" -> :ok end,\n      join_channel: fn \"guild-1\", \"channel-1\" ->\n        send(test_pid, :join_attempted)\n        :ok\n      end do\n      send(AudioPlayer, :check_voice_connection)\n\n      # leave→rejoin includes a 1s sleep between leave and join\n      assert_receive :join_attempted, 2_000\n    end\n  end\n\n  describe \"idle timeout\" do\n    test \"schedules idle timeout when voice channel is set (play mode)\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      AudioPlayer.set_voice_channel(\"guild-1\", \"ch-1\")\n      # Sync: the call queues after the cast, ensuring the cast is processed first\n      AudioPlayer.current_voice_channel()\n\n      state = :sys.get_state(AudioPlayer)\n      assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n      assert state.idle_timeout_ref != nil\n    end\n\n    test \"does not schedule idle timeout in presence mode\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mock AutoJoinPolicy, mode: fn -> :presence end do\n        AudioPlayer.set_voice_channel(\"guild-1\", \"ch-1\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n        assert state.idle_timeout_ref == nil\n      end\n    end\n\n    test \"does not schedule idle timeout in false mode on join\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mock AutoJoinPolicy, mode: fn -> false end do\n        AudioPlayer.set_voice_channel(\"guild-1\", \"ch-1\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n        assert state.idle_timeout_ref == nil\n      end\n    end\n\n    test \"cancels idle timeout when voice channel is cleared\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: {make_ref(), make_ref()}}\n      end)\n\n      AudioPlayer.set_voice_channel(nil, nil)\n      AudioPlayer.current_voice_channel()\n\n      state = :sys.get_state(AudioPlayer)\n      assert state.voice_channel == nil\n      assert state.idle_timeout_ref == nil\n    end\n\n    test \"resets idle timeout when a sound is played (play mode)\" do\n      token = make_ref()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{\n          state\n          | voice_channel: {\"guild-1\", \"ch-1\"},\n            idle_timeout_ref: {make_ref(), token},\n            current_playback: nil,\n            pending_request: nil,\n            interrupting: false,\n            interrupt_watchdog_attempt: 0\n        }\n      end)\n\n      with_mocks([\n        {Soundboard.AudioPlayer.SoundLibrary, [],\n         [get_sound_path: fn \"test.mp3\" -> {:ok, {\"/path/test.mp3\", 1.0}} end]},\n        {Soundboard.AudioPlayer.PlaybackEngine, [], [play: fn _, _, _, _, _, _ -> :ok end]}\n      ]) do\n        AudioPlayer.play_sound(\"test.mp3\", \"actor\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        # A new token means the timer was reset\n        {_ref, new_token} = state.idle_timeout_ref\n        assert new_token != token\n      end\n    end\n\n    test \"does not reset idle timeout when a sound is played in presence mode\" do\n      token = make_ref()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{\n          state\n          | voice_channel: {\"guild-1\", \"ch-1\"},\n            idle_timeout_ref: {make_ref(), token},\n            current_playback: nil,\n            pending_request: nil,\n            interrupting: false,\n            interrupt_watchdog_attempt: 0\n        }\n      end)\n\n      with_mocks([\n        {AutoJoinPolicy, [], [mode: fn -> :presence end]},\n        {Soundboard.AudioPlayer.SoundLibrary, [],\n         [get_sound_path: fn \"test.mp3\" -> {:ok, {\"/path/test.mp3\", 1.0}} end]},\n        {Soundboard.AudioPlayer.PlaybackEngine, [], [play: fn _, _, _, _, _, _ -> :ok end]}\n      ]) do\n        AudioPlayer.play_sound(\"test.mp3\", \"actor\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        {_ref, unchanged_token} = state.idle_timeout_ref\n        assert unchanged_token == token\n      end\n    end\n\n    test \"idle timeout fires and leaves the voice channel\" do\n      test_pid = self()\n      token = make_ref()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{\n          state\n          | voice_channel: {\"guild-1\", \"ch-1\"},\n            idle_timeout_ref: {make_ref(), token},\n            current_playback: nil\n        }\n      end)\n\n      with_mock Voice,\n        leave_channel: fn \"guild-1\" ->\n          send(test_pid, :leave_called)\n          :ok\n        end do\n        send(AudioPlayer, {:idle_timeout, token})\n        assert_receive :leave_called, 1_000\n\n        AudioPlayer.current_voice_channel()\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == nil\n        assert state.idle_timeout_ref == nil\n      end\n    end\n\n    test \"stale idle timeout tokens are ignored\" do\n      test_pid = self()\n      active_token = make_ref()\n      stale_token = make_ref()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{\n          state\n          | voice_channel: {\"guild-1\", \"ch-1\"},\n            idle_timeout_ref: {make_ref(), active_token}\n        }\n      end)\n\n      with_mock Voice,\n        leave_channel: fn _ ->\n          send(test_pid, :leave_called)\n          :ok\n        end do\n        send(AudioPlayer, {:idle_timeout, stale_token})\n        # Sync, then verify nothing happened\n        AudioPlayer.current_voice_channel()\n        refute_receive :leave_called, 100\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n      end\n    end\n  end\n\n  describe \"last_user_left\" do\n    test \"leaves immediately in play mode\" do\n      test_pid = self()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: nil}\n      end)\n\n      with_mock Voice,\n        leave_channel: fn \"guild-1\" ->\n          send(test_pid, :leave_called)\n          :ok\n        end do\n        AudioPlayer.last_user_left(\"guild-1\")\n        assert_receive :leave_called, 1_000\n\n        AudioPlayer.current_voice_channel()\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == nil\n        assert state.idle_timeout_ref == nil\n      end\n    end\n\n    test \"leaves immediately in presence mode\" do\n      test_pid = self()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {AutoJoinPolicy, [], [mode: fn -> :presence end]},\n        {Voice, [],\n         [\n           leave_channel: fn \"guild-1\" ->\n             send(test_pid, :leave_called)\n             :ok\n           end\n         ]}\n      ]) do\n        AudioPlayer.last_user_left(\"guild-1\")\n        assert_receive :leave_called, 1_000\n\n        AudioPlayer.current_voice_channel()\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == nil\n      end\n    end\n\n    test \"starts idle timer in false mode (with timeout configured)\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {AutoJoinPolicy, [], [mode: fn -> false end]},\n        {Soundboard.Discord.Handler.IdleTimeoutPolicy, [], [timeout_ms: fn -> 60_000 end]},\n        {Voice, [], [leave_channel: fn _ -> :ok end]}\n      ]) do\n        AudioPlayer.last_user_left(\"guild-1\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n        assert state.idle_timeout_ref != nil\n        refute called(Voice.leave_channel(:_))\n      end\n    end\n\n    test \"stays in false mode when timeout is disabled (0)\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {AutoJoinPolicy, [], [mode: fn -> false end]},\n        {Soundboard.Discord.Handler.IdleTimeoutPolicy, [], [timeout_ms: fn -> nil end]},\n        {Voice, [], [leave_channel: fn _ -> :ok end]}\n      ]) do\n        AudioPlayer.last_user_left(\"guild-1\")\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-1\"}\n        assert state.idle_timeout_ref == nil\n        refute called(Voice.leave_channel(:_))\n      end\n    end\n\n    test \"ignores last_user_left when bot is not in a channel\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil}\n      end)\n\n      with_mock Voice, leave_channel: fn _ -> :ok end do\n        AudioPlayer.last_user_left(\"guild-1\")\n        AudioPlayer.current_voice_channel()\n\n        refute called(Voice.leave_channel(:_))\n      end\n    end\n  end\n\n  describe \"user_joined_channel\" do\n    test \"cancels idle timer\" do\n      token = make_ref()\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: {\"guild-1\", \"ch-1\"}, idle_timeout_ref: {make_ref(), token}}\n      end)\n\n      AudioPlayer.user_joined_channel(\"guild-1\")\n      AudioPlayer.current_voice_channel()\n\n      state = :sys.get_state(AudioPlayer)\n      assert state.idle_timeout_ref == nil\n    end\n  end\n\n  describe \"auto-join on play\" do\n    test \"auto-joins user's voice channel when bot has no channel and actor has discord_id\" do\n      test_pid = self()\n      user = %User{discord_id: \"discord-99\", username: \"tester\", id: 1}\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {VoicePresence, [],\n         [find_user_voice_channel: fn \"discord-99\" -> {:ok, {\"guild-1\", \"ch-5\"}} end]},\n        {Voice, [],\n         [\n           join_channel: fn \"guild-1\", \"ch-5\" ->\n             send(test_pid, :join_called)\n             :ok\n           end\n         ]},\n        {Soundboard.AudioPlayer.SoundLibrary, [],\n         [get_sound_path: fn _ -> {:error, \"not found\"} end]}\n      ]) do\n        AudioPlayer.play_sound(\"any.mp3\", user)\n        assert_receive :join_called, 1_000\n\n        AudioPlayer.current_voice_channel()\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == {\"guild-1\", \"ch-5\"}\n        assert state.idle_timeout_ref != nil\n      end\n    end\n\n    test \"shows error and skips auto-join when user is not in any voice channel\" do\n      user = %User{discord_id: \"discord-99\", username: \"tester\", id: 1}\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {VoicePresence, [], [find_user_voice_channel: fn \"discord-99\" -> :not_found end]},\n        {Voice, [], [join_channel: fn _, _ -> :ok end]}\n      ]) do\n        AudioPlayer.play_sound(\"any.mp3\", user)\n        AudioPlayer.current_voice_channel()\n\n        state = :sys.get_state(AudioPlayer)\n        assert state.voice_channel == nil\n\n        refute called(Voice.join_channel(:_, :_))\n      end\n    end\n\n    test \"skips auto-join for actors without discord_id\" do\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {VoicePresence, [], [find_user_voice_channel: fn _ -> {:ok, {\"guild-1\", \"ch-1\"}} end]},\n        {Voice, [], [join_channel: fn _, _ -> :ok end]}\n      ]) do\n        AudioPlayer.play_sound(\"any.mp3\", \"System\")\n        AudioPlayer.current_voice_channel()\n\n        refute called(VoicePresence.find_user_voice_channel(:_))\n        refute called(Voice.join_channel(:_, :_))\n      end\n    end\n\n    test \"skips auto-join in false mode\" do\n      user = %User{discord_id: \"discord-99\", username: \"tester\", id: 1}\n\n      :sys.replace_state(AudioPlayer, fn state ->\n        %{state | voice_channel: nil, idle_timeout_ref: nil}\n      end)\n\n      with_mocks([\n        {AutoJoinPolicy, [], [mode: fn -> false end]},\n        {VoicePresence, [], [find_user_voice_channel: fn _ -> {:ok, {\"guild-1\", \"ch-1\"}} end]},\n        {Voice, [], [join_channel: fn _, _ -> :ok end]}\n      ]) do\n        AudioPlayer.play_sound(\"any.mp3\", user)\n        AudioPlayer.current_voice_channel()\n\n        refute called(VoicePresence.find_user_voice_channel(:_))\n        refute called(Voice.join_channel(:_, :_))\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/components/layouts/navbar_test.exs",
    "content": "defmodule SoundboardWeb.Components.Layouts.NavbarTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveViewTest\n\n  alias SoundboardWeb.Components.Layouts.Navbar\n\n  test \"renders public navigation links\" do\n    html =\n      render_component(Navbar,\n        id: \"navbar\",\n        current_path: \"/\",\n        current_user: nil,\n        presences: %{}\n      )\n\n    assert html =~ \"SoundBored\"\n    assert html =~ \"Sounds\"\n    assert html =~ \"Favorites\"\n    assert html =~ \"Stats\"\n    refute html =~ \"Settings\"\n  end\n\n  test \"renders settings link and deduplicated presences for authenticated users\" do\n    html =\n      render_component(Navbar,\n        id: \"navbar\",\n        current_path: \"/settings\",\n        current_user: %{id: 1, username: \"owner\"},\n        presences: %{\n          \"1\" => %{metas: [%{user: %{username: \"alice\", avatar: \"alice.png\"}}]},\n          \"2\" => %{metas: [%{user: %{username: \"alice\", avatar: \"alice.png\"}}]},\n          \"3\" => %{metas: [%{user: %{username: \"bob\", avatar: \"bob.png\"}}]}\n        }\n      )\n\n    assert html =~ \"Settings\"\n    assert html =~ \"user-alice\"\n    assert html =~ \"user-bob\"\n\n    # Duplicated presence entries for the same user should only render once per menu section.\n    assert length(Regex.scan(~r/user-alice/, html)) == 2\n  end\n\n  test \"toggle-mobile-menu flips show_mobile_menu assign\" do\n    {:ok, socket} = Navbar.mount(%Phoenix.LiveView.Socket{})\n\n    {:noreply, socket} = Navbar.handle_event(\"toggle-mobile-menu\", %{}, socket)\n    assert socket.assigns.show_mobile_menu\n\n    {:noreply, socket} = Navbar.handle_event(\"toggle-mobile-menu\", %{}, socket)\n    refute socket.assigns.show_mobile_menu\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/components/soundboard/edit_modal_test.exs",
    "content": "defmodule SoundboardWeb.Components.Soundboard.EditModalTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveViewTest\n\n  alias SoundboardWeb.Components.Soundboard.EditModal\n\n  test \"renders edit form with local file metadata\" do\n    html = render_component(&EditModal.edit_modal/1, edit_assigns())\n\n    assert html =~ \"Edit Sound\"\n    assert html =~ \"Local File\"\n    assert html =~ \"Save Changes\"\n    assert html =~ \"Delete Sound\"\n  end\n\n  test \"renders source URL and edit validation errors\" do\n    html =\n      render_component(\n        &EditModal.edit_modal/1,\n        edit_assigns(%{\n          current_sound: %{\n            edit_sound()\n            | source_type: \"url\",\n              url: \"https://example.com/sound.mp3\"\n          },\n          edit_name_error: \"Name already taken\"\n        })\n      )\n\n    assert html =~ \"URL: https://example.com/sound.mp3\"\n    assert html =~ \"Name already taken\"\n  end\n\n  test \"hides delete for non-owners\" do\n    html =\n      render_component(\n        &EditModal.edit_modal/1,\n        edit_assigns(%{\n          current_user: %{id: 2}\n        })\n      )\n\n    refute html =~ \"Delete Sound\"\n  end\n\n  defp edit_assigns(overrides \\\\ %{}) do\n    base = %{\n      current_sound: edit_sound(),\n      current_user: %{id: 1},\n      tag_input: \"\",\n      tag_suggestions: [],\n      edit_name_error: nil,\n      flash: %{}\n    }\n\n    Map.merge(base, overrides)\n  end\n\n  defp edit_sound do\n    %{\n      id: 10,\n      filename: \"laser.mp3\",\n      source_type: \"local\",\n      url: nil,\n      volume: 1.0,\n      tags: [%{name: \"funny\"}],\n      user_id: 1,\n      user_sound_settings: [%{user_id: 1, is_join_sound: true, is_leave_sound: false}]\n    }\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/components/soundboard/upload_modal_test.exs",
    "content": "defmodule SoundboardWeb.Components.Soundboard.UploadModalTest do\n  use ExUnit.Case, async: true\n\n  import Phoenix.LiveViewTest\n\n  alias SoundboardWeb.Components.Soundboard.UploadModal\n\n  test \"renders url workflow and prompts for missing URL\" do\n    html = render_component(&UploadModal.upload_modal/1, upload_assigns())\n\n    assert html =~ \"Add Sound\"\n    assert html =~ \"Source Type\"\n    assert html =~ \"Enter a URL first to name it.\"\n  end\n\n  test \"renders filled URL workflow without missing-url helper text\" do\n    html =\n      render_component(\n        &UploadModal.upload_modal/1,\n        upload_assigns(%{\n          source_type: \"url\",\n          url: \"https://example.com/clip.mp3\",\n          upload_name: \"clip\"\n        })\n      )\n\n    assert html =~ \"upload-url-input\"\n    refute html =~ \"Enter a URL first to name it.\"\n  end\n\n  test \"disables submit when upload validation error is present\" do\n    html =\n      render_component(\n        &UploadModal.upload_modal/1,\n        upload_assigns(%{\n          source_type: \"url\",\n          url: \"https://example.com/clip.mp3\",\n          upload_name: \"clip\",\n          upload_error: \"URL is invalid\"\n        })\n      )\n\n    assert html =~ \"disabled\"\n    assert html =~ \"URL is invalid\"\n  end\n\n  defp upload_assigns(overrides \\\\ %{}) do\n    base = %{\n      source_type: \"url\",\n      uploads: %{audio: %{entries: []}},\n      url: \"\",\n      upload_name: \"\",\n      upload_error: nil,\n      upload_tags: [],\n      upload_tag_input: \"\",\n      upload_tag_suggestions: [],\n      upload_volume: 100,\n      is_join_sound: false,\n      is_leave_sound: false\n    }\n\n    Map.merge(base, overrides)\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/controllers/api/sound_controller_test.exs",
    "content": "defmodule SoundboardWeb.API.SoundControllerTest do\n  @moduledoc \"\"\"\n  Test for the SoundController.\n  \"\"\"\n  use SoundboardWeb.ConnCase\n\n  import Mock\n\n  alias Soundboard.Accounts.{ApiTokens, User}\n  alias Soundboard.{Repo, Sound, Tag, UserSoundSetting}\n\n  setup %{conn: conn} do\n    user = insert_user()\n    sound = insert_sound(user)\n    tag = insert_tag()\n\n    {:ok, raw_token, _token} = ApiTokens.generate_token(user, %{label: \"API Test\"})\n\n    insert_sound_tag(sound, tag)\n\n    conn =\n      conn\n      |> put_req_header(\"authorization\", \"Bearer \" <> raw_token)\n\n    %{conn: conn, sound: sound, user: user}\n  end\n\n  describe \"index\" do\n    test \"lists all sounds with their tags\", %{conn: conn} do\n      conn = get(conn, ~p\"/api/sounds\")\n      assert %{\"data\" => sounds} = json_response(conn, 200)\n\n      Enum.each(sounds, fn sound_data ->\n        assert is_integer(sound_data[\"id\"])\n        assert is_binary(sound_data[\"filename\"])\n        assert is_list(sound_data[\"tags\"])\n        assert sound_data[\"inserted_at\"]\n        assert sound_data[\"updated_at\"]\n      end)\n    end\n\n    test \"returns sounds in expected format\", %{conn: conn, sound: sound} do\n      conn = get(conn, ~p\"/api/sounds\")\n      assert %{\"data\" => sounds} = json_response(conn, 200)\n\n      test_sound = Enum.find(sounds, &(&1[\"id\"] == sound.id))\n      assert test_sound\n      assert test_sound[\"filename\"] == sound.filename\n      assert is_list(test_sound[\"tags\"])\n    end\n\n    test \"includes join and leave flags for the authenticated user\", %{\n      conn: conn,\n      sound: sound,\n      user: user\n    } do\n      %UserSoundSetting{}\n      |> UserSoundSetting.changeset(%{\n        user_id: user.id,\n        sound_id: sound.id,\n        is_join_sound: true,\n        is_leave_sound: false\n      })\n      |> Repo.insert!()\n\n      conn = get(conn, ~p\"/api/sounds\")\n      assert %{\"data\" => sounds} = json_response(conn, 200)\n\n      test_sound = Enum.find(sounds, &(&1[\"id\"] == sound.id))\n      assert test_sound[\"is_join_sound\"] == true\n      assert test_sound[\"is_leave_sound\"] == false\n    end\n  end\n\n  describe \"create\" do\n    test \"creates a URL sound\", %{conn: conn, user: user} do\n      name = \"api_url_#{System.unique_integer([:positive])}\"\n\n      conn =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => name,\n          \"url\" => \"https://example.com/wow.mp3\",\n          \"tags\" => [\"meme\", \"reaction\"],\n          \"volume\" => \"35\",\n          \"is_join_sound\" => \"true\"\n        })\n\n      assert %{\"data\" => data} = json_response(conn, 201)\n      assert data[\"filename\"] == \"#{name}.mp3\"\n      assert data[\"source_type\"] == \"url\"\n      assert data[\"url\"] == \"https://example.com/wow.mp3\"\n      assert data[\"is_join_sound\"] == true\n\n      sound = Repo.get_by!(Sound, filename: \"#{name}.mp3\") |> Repo.preload(:tags)\n      assert Enum.sort(Enum.map(sound.tags, & &1.name)) == [\"meme\", \"reaction\"]\n      assert_in_delta sound.volume, 0.35, 0.0001\n\n      setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: sound.id)\n      assert setting.is_join_sound\n      refute setting.is_leave_sound\n    end\n\n    test \"infers URL source type when source_type is omitted\", %{conn: conn} do\n      name = \"api_url_inferred_#{System.unique_integer([:positive])}\"\n\n      conn =\n        post(conn, ~p\"/api/sounds\", %{\n          \"name\" => name,\n          \"url\" => \"https://example.com/inferred.mp3\"\n        })\n\n      assert %{\"data\" => data} = json_response(conn, 201)\n      assert data[\"source_type\"] == \"url\"\n      assert data[\"filename\"] == \"#{name}.mp3\"\n    end\n\n    test \"creates a local multipart sound and saves the file\", %{conn: conn} do\n      name = \"api_local_#{System.unique_integer([:positive])}\"\n      tmp_path = temp_upload_path(\"sample.mp3\")\n      File.write!(tmp_path, \"audio\")\n\n      on_exit(fn -> File.rm(tmp_path) end)\n\n      upload = %Plug.Upload{path: tmp_path, filename: \"sample.mp3\", content_type: \"audio/mpeg\"}\n\n      conn =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"local\",\n          \"name\" => name,\n          \"file\" => upload,\n          \"tags\" => \"api,local\",\n          \"volume\" => \"120\",\n          \"is_leave_sound\" => \"true\"\n        })\n\n      assert %{\"data\" => data} = json_response(conn, 201)\n      assert data[\"filename\"] == \"#{name}.mp3\"\n      assert data[\"source_type\"] == \"local\"\n      assert data[\"is_leave_sound\"] == true\n\n      sound = Repo.get_by!(Sound, filename: \"#{name}.mp3\")\n      assert_in_delta sound.volume, 1.2, 0.0001\n\n      copied_file = Path.join(uploads_dir(), sound.filename)\n      assert File.exists?(copied_file)\n\n      on_exit(fn -> File.rm(copied_file) end)\n    end\n\n    test \"infers local source type when multipart file is present\", %{conn: conn} do\n      name = \"api_local_inferred_#{System.unique_integer([:positive])}\"\n      tmp_path = temp_upload_path(\"inferred.mp3\")\n      File.write!(tmp_path, \"audio\")\n\n      on_exit(fn -> File.rm(tmp_path) end)\n\n      upload = %Plug.Upload{path: tmp_path, filename: \"inferred.mp3\", content_type: \"audio/mpeg\"}\n\n      conn =\n        post(conn, ~p\"/api/sounds\", %{\n          \"name\" => name,\n          \"file\" => upload\n        })\n\n      assert %{\"data\" => data} = json_response(conn, 201)\n      assert data[\"source_type\"] == \"local\"\n      assert data[\"filename\"] == \"#{name}.mp3\"\n\n      on_exit(fn -> File.rm(Path.join(uploads_dir(), \"#{name}.mp3\")) end)\n    end\n\n    test \"clears previous join sound when creating a new join sound\", %{conn: conn, user: user} do\n      first_name = \"join_one_#{System.unique_integer([:positive])}\"\n      second_name = \"join_two_#{System.unique_integer([:positive])}\"\n\n      _ =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => first_name,\n          \"url\" => \"https://example.com/first.mp3\",\n          \"is_join_sound\" => \"true\"\n        })\n\n      _ =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => second_name,\n          \"url\" => \"https://example.com/second.mp3\",\n          \"is_join_sound\" => \"true\"\n        })\n\n      first_sound = Repo.get_by!(Sound, filename: \"#{first_name}.mp3\")\n      second_sound = Repo.get_by!(Sound, filename: \"#{second_name}.mp3\")\n\n      first_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: first_sound.id)\n      second_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: second_sound.id)\n\n      refute first_setting.is_join_sound\n      assert second_setting.is_join_sound\n    end\n\n    test \"returns validation errors for missing fields\", %{conn: conn} do\n      conn_missing_name =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"url\" => \"https://example.com/missing-name.mp3\"\n        })\n\n      assert %{\"errors\" => errors} = json_response(conn_missing_name, 422)\n      assert \"can't be blank\" in errors[\"filename\"]\n\n      conn_missing_url =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => \"missing_url\"\n        })\n\n      assert %{\"errors\" => errors} = json_response(conn_missing_url, 422)\n      assert \"can't be blank\" in errors[\"url\"]\n\n      conn_missing_file =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"local\",\n          \"name\" => \"missing_file\"\n        })\n\n      assert %{\"errors\" => errors} = json_response(conn_missing_file, 422)\n      assert \"Please select a file\" in errors[\"file\"]\n    end\n\n    test \"returns validation error for duplicate filename\", %{conn: conn, user: user} do\n      duplicate_name = \"dup_#{System.unique_integer([:positive])}\"\n\n      {:ok, _} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"#{duplicate_name}.mp3\",\n          source_type: \"url\",\n          url: \"https://example.com/original.mp3\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      conn =\n        post(conn, ~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => duplicate_name,\n          \"url\" => \"https://example.com/new.mp3\"\n        })\n\n      assert %{\"errors\" => errors} = json_response(conn, 422)\n      assert \"has already been taken\" in errors[\"filename\"]\n    end\n\n    test \"returns unauthorized without valid token\" do\n      conn =\n        build_conn()\n        |> put_req_header(\"authorization\", \"Bearer badtoken\")\n        |> post(~p\"/api/sounds\", %{\n          \"source_type\" => \"url\",\n          \"name\" => \"invalid\",\n          \"url\" => \"https://example.com/test.mp3\"\n        })\n\n      assert json_response(conn, 401)\n    end\n  end\n\n  describe \"play\" do\n    test \"plays a sound as the authenticated token user\", %{conn: conn, sound: sound, user: user} do\n      with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _actor -> :ok end do\n        conn = post(conn, ~p\"/api/sounds/#{sound.id}/play\")\n\n        assert %{\n                 \"data\" => %{\n                   \"status\" => \"accepted\",\n                   \"message\" => \"Playback request accepted for \" <> _,\n                   \"requested_by\" => requested_by,\n                   \"sound\" => %{\"id\" => sound_id, \"filename\" => filename}\n                 }\n               } = json_response(conn, 202)\n\n        assert requested_by == user.username\n        assert sound_id == sound.id\n        assert filename == sound.filename\n        assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))\n      end\n    end\n\n    test \"ignores x-username and attributes playback to the token user\", %{\n      conn: conn,\n      sound: sound,\n      user: user\n    } do\n      with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _actor -> :ok end do\n        conn =\n          conn\n          |> put_req_header(\"x-username\", \"TestUser\")\n          |> post(~p\"/api/sounds/#{sound.id}/play\")\n\n        assert %{\n                 \"data\" => %{\n                   \"requested_by\" => requested_by,\n                   \"sound\" => %{\"id\" => sound_id, \"filename\" => filename}\n                 }\n               } = json_response(conn, 202)\n\n        assert requested_by == user.username\n        assert sound_id == sound.id\n        assert filename == sound.filename\n        assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))\n      end\n    end\n\n    test \"returns error when sound not found\", %{conn: conn} do\n      with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _username -> :ok end do\n        conn = post(conn, ~p\"/api/sounds/999999/play\")\n        assert %{\"error\" => \"Sound not found\"} = json_response(conn, 404)\n      end\n    end\n\n    test \"returns unauthorized without valid API token\" do\n      conn = build_conn()\n      conn = post(conn, ~p\"/api/sounds/1/play\")\n      assert json_response(conn, 401)\n    end\n  end\n\n  defp insert_sound(user) do\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test_sound#{System.unique_integer([:positive])}.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      })\n      |> Repo.insert()\n\n    sound\n  end\n\n  defp insert_tag do\n    {:ok, tag} =\n      %Tag{}\n      |> Tag.changeset(%{name: \"test_tag#{System.unique_integer([:positive])}\"})\n      |> Repo.insert()\n\n    tag\n  end\n\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser#{System.unique_integer([:positive])}\",\n        discord_id: \"#{System.unique_integer([:positive])}\",\n        avatar: \"test_avatar.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\n\n  defp insert_sound_tag(sound, tag) do\n    {:ok, _} =\n      %Soundboard.SoundTag{}\n      |> Soundboard.SoundTag.changeset(%{\n        sound_id: sound.id,\n        tag_id: tag.id\n      })\n      |> Repo.insert()\n  end\n\n  defp uploads_dir do\n    Soundboard.UploadsPath.dir()\n  end\n\n  defp temp_upload_path(filename) do\n    Path.join(System.tmp_dir!(), \"#{System.unique_integer([:positive])}-#{filename}\")\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/controllers/auth_controller_test.exs",
    "content": "defmodule SoundboardWeb.AuthControllerTest do\n  use SoundboardWeb.ConnCase\n  alias Soundboard.{Accounts.User, Repo}\n  import ExUnit.CaptureLog\n  import Mock\n  alias EDA.API.Member\n\n  setup %{conn: conn} do\n    # Clean up users before each test\n    Repo.delete_all(User)\n\n    # Initialize session and CSRF token for all tests\n    conn =\n      conn\n      |> init_test_session(%{})\n      |> fetch_session()\n      |> fetch_flash()\n\n    # Mock Discord OAuth config for tests\n    Application.put_env(:ueberauth, Ueberauth.Strategy.Discord.OAuth,\n      client_id: \"test_client_id\",\n      client_secret: \"test_client_secret\"\n    )\n\n    on_exit(fn ->\n      Application.delete_env(:ueberauth, Ueberauth.Strategy.Discord.OAuth)\n    end)\n\n    {:ok, conn: conn}\n  end\n\n  describe \"auth flow\" do\n    test \"request/2 initiates Discord auth and sets session\", %{conn: conn} do\n      conn = get(conn, ~p\"/auth/discord\")\n\n      # Redirect status\n      assert conn.status == 302\n\n      assert String.starts_with?(\n               redirected_to(conn),\n               \"https://discord.com/api/oauth2/authorize\"\n             )\n    end\n\n    test \"request/2 rejects unsupported providers with a controlled 404\", %{conn: conn} do\n      conn = get(conn, \"/auth/not-real\")\n\n      assert response(conn, 404) == \"Unsupported auth provider\"\n    end\n\n    test \"callback/2 creates new user on successful auth\", %{conn: conn} do\n      auth_data = %{\n        uid: \"12345\",\n        info: %{\n          nickname: \"TestUser\",\n          image: \"test_avatar.jpg\"\n        }\n      }\n\n      conn =\n        conn\n        |> assign(:ueberauth_auth, auth_data)\n        |> get(~p\"/auth/discord/callback\")\n\n      assert redirected_to(conn) == \"/\"\n      assert get_session(conn, :user_id)\n\n      user = Repo.get_by(User, discord_id: \"12345\")\n      assert user\n      assert user.username == \"TestUser\"\n      assert user.avatar == \"test_avatar.jpg\"\n    end\n\n    test \"callback/2 uses existing user if found\", %{conn: conn} do\n      # Get initial user count\n      initial_count = Repo.aggregate(User, :count)\n\n      # Create existing user\n      {:ok, existing_user} =\n        %User{}\n        |> User.changeset(%{\n          discord_id: \"12345\",\n          username: \"ExistingUser\",\n          avatar: \"old_avatar.jpg\"\n        })\n        |> Repo.insert()\n\n      auth_data = %{\n        uid: \"12345\",\n        info: %{\n          nickname: \"TestUser\",\n          image: \"test_avatar.jpg\"\n        }\n      }\n\n      conn =\n        conn\n        |> assign(:ueberauth_auth, auth_data)\n        |> get(~p\"/auth/discord/callback\")\n\n      final_count = Repo.aggregate(User, :count)\n\n      assert redirected_to(conn) == \"/\"\n      assert get_session(conn, :user_id) == existing_user.id\n      # Only increased by the one we created\n      assert final_count == initial_count + 1\n    end\n\n    test \"callback/2 handles auth failures\", %{conn: conn} do\n      capture_log(fn ->\n        conn =\n          conn\n          |> assign(:ueberauth_failure, %{\n            errors: [\n              %Ueberauth.Failure.Error{\n                message_key: \"invalid_credentials\",\n                message: \"Invalid credentials\"\n              }\n            ]\n          })\n          |> get(~p\"/auth/discord/callback\")\n\n        assert redirected_to(conn) == \"/\"\n        assert Phoenix.Flash.get(conn.assigns.flash, :error) == \"Failed to authenticate\"\n      end)\n    end\n\n    test \"logout/2 clears session and redirects\", %{conn: conn} do\n      conn =\n        conn\n        |> put_session(:user_id, \"test_id\")\n        |> delete(~p\"/auth/logout\")\n\n      assert redirected_to(conn) == \"/\"\n      refute get_session(conn, :user_id)\n    end\n\n    test \"debug_session/2 returns limited session info\", %{conn: conn} do\n      user = insert_user()\n\n      conn =\n        conn\n        |> put_session(:session_id, 123)\n        |> put_session(:user_id, user.id)\n        |> get(~p\"/debug/session\")\n\n      assert json = json_response(conn, 200)\n      assert json == %{\"session\" => %{\"session_id\" => 123, \"user_id\" => user.id}}\n    end\n  end\n\n  describe \"role-gated access\" do\n    test \"callback/2 sets roles_verified_at session key on successful auth\", %{conn: conn} do\n      # Feature is disabled in test env (no guild_id/role_ids configured),\n      # so RoleChecker.authorized?/1 returns true without any mocking.\n      auth_data = %{\n        uid: \"99999\",\n        info: %{\n          nickname: \"RoleUser\",\n          image: \"role_avatar.jpg\"\n        }\n      }\n\n      conn =\n        conn\n        |> assign(:ueberauth_auth, auth_data)\n        |> get(~p\"/auth/discord/callback\")\n\n      assert redirected_to(conn) == \"/\"\n      assert get_session(conn, :user_id)\n      assert is_integer(get_session(conn, :roles_verified_at))\n    end\n\n    test \"callback/2 rejects unauthorized user without creating user record\", %{conn: conn} do\n      previous_guild = Application.get_env(:soundboard, :required_guild_id)\n      previous_roles = Application.get_env(:soundboard, :required_role_ids)\n\n      Application.put_env(:soundboard, :required_guild_id, \"test_guild\")\n      Application.put_env(:soundboard, :required_role_ids, [\"required_role\"])\n\n      on_exit(fn ->\n        if is_nil(previous_guild),\n          do: Application.delete_env(:soundboard, :required_guild_id),\n          else: Application.put_env(:soundboard, :required_guild_id, previous_guild)\n\n        if is_nil(previous_roles),\n          do: Application.delete_env(:soundboard, :required_role_ids),\n          else: Application.put_env(:soundboard, :required_role_ids, previous_roles)\n      end)\n\n      user_count_before = Repo.aggregate(User, :count)\n\n      auth_data = %{\n        uid: \"unauthorized_user\",\n        info: %{\n          nickname: \"UnauthorizedUser\",\n          image: \"avatar.jpg\"\n        }\n      }\n\n      with_mock Member,\n        get: fn \"test_guild\", \"unauthorized_user\" -> {:ok, %{\"roles\" => [\"other_role\"]}} end do\n        conn =\n          conn\n          |> assign(:ueberauth_auth, auth_data)\n          |> get(~p\"/auth/discord/callback\")\n\n        assert redirected_to(conn) == \"/\"\n\n        assert Phoenix.Flash.get(conn.assigns.flash, :error) == \"Error signing in\"\n\n        refute get_session(conn, :user_id)\n        assert Repo.aggregate(User, :count) == user_count_before\n      end\n    end\n  end\n\n  # Helper function\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser#{System.unique_integer([:positive])}\",\n        discord_id: \"#{System.unique_integer([:positive])}\",\n        avatar: \"test_avatar.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/controllers/upload_controller_test.exs",
    "content": "defmodule SoundboardWeb.UploadControllerTest do\n  use SoundboardWeb.ConnCase\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Repo\n\n  setup %{conn: conn} do\n    filename = \"upload_controller_#{System.unique_integer([:positive])}.mp3\"\n    uploads_dir = Soundboard.UploadsPath.dir()\n    file_path = Path.join(uploads_dir, filename)\n\n    File.mkdir_p!(uploads_dir)\n    File.write!(file_path, \"audio\")\n\n    on_exit(fn -> File.rm(file_path) end)\n\n    %{conn: conn, filename: filename}\n  end\n\n  test \"GET /uploads/*path redirects unauthenticated users\", %{conn: conn, filename: filename} do\n    conn = get(conn, ~p\"/uploads/#{filename}\")\n\n    assert redirected_to(conn) == \"/auth/discord\"\n  end\n\n  test \"GET /uploads/*path serves files for authenticated users\", %{\n    conn: conn,\n    filename: filename\n  } do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"upload_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    conn =\n      conn\n      |> init_test_session(%{user_id: user.id})\n      |> get(~p\"/uploads/#{filename}\")\n\n    assert response(conn, 200) == \"audio\"\n  end\n\n  test \"GET /uploads/*path rejects traversal attempts\", %{conn: conn} do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"upload_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"avatar.png\"\n      })\n      |> Repo.insert()\n\n    conn =\n      conn\n      |> init_test_session(%{user_id: user.id})\n      |> get(\"/uploads/../../mix.exs\")\n\n    assert response(conn, 404) == \"File not found\"\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/discord_handler_test.exs",
    "content": "defmodule Soundboard.Discord.HandlerTest do\n  @moduledoc \"\"\"\n  Tests the DiscordHandler module.\n  \"\"\"\n  use Soundboard.DataCase\n\n  import ExUnit.CaptureLog\n  import Mock\n\n  alias Soundboard.{Accounts.User, Repo, Sound, UserSoundSetting}\n  alias Soundboard.Discord.Handler\n  alias Soundboard.Discord.Voice\n\n  setup do\n    :persistent_term.put(:soundboard_bot_ready, true)\n\n    on_exit(fn ->\n      :persistent_term.erase(:soundboard_bot_ready)\n    end)\n\n    :ok\n  end\n\n  describe \"handle_event/1\" do\n    test \"handles voice state updates\" do\n      mock_guild = %{\n        id: \"456\",\n        voice_states: [\n          %{\n            user_id: \"789\",\n            channel_id: \"123\",\n            guild_id: \"456\",\n            session_id: \"abc\"\n          }\n        ]\n      }\n\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.Discord.Handler.AutoJoinPolicy, [], [mode: fn -> :presence end]},\n          {Soundboard.Discord.Voice, [],\n           [\n             join_channel: fn _, _ -> :ok end,\n             ready?: fn _ -> false end\n           ]},\n          {Soundboard.Discord.GuildCache, [], [get: fn _guild_id -> {:ok, mock_guild} end]},\n          {Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: \"999\"}} end]}\n        ]) do\n          payload = %{\n            channel_id: \"123\",\n            guild_id: \"456\",\n            user_id: \"789\",\n            session_id: \"abc\"\n          }\n\n          Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})\n\n          assert_called(Voice.join_channel(\"456\", \"123\"))\n        end\n      end)\n    end\n\n    test \"does not auto-join when guild cache is unavailable\" do\n      {:ok, recorder} = Agent.start_link(fn -> [] end)\n\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.Discord.Voice, [],\n           [\n             join_channel: fn guild_id, channel_id ->\n               Agent.update(recorder, &(&1 ++ [{guild_id, channel_id}]))\n               :ok\n             end,\n             ready?: fn _ -> false end\n           ]},\n          {Soundboard.Discord.GuildCache, [],\n           [all: fn -> [] end, get: fn _guild_id -> :error end]},\n          {Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: \"999\"}} end]}\n        ]) do\n          payload = %{\n            channel_id: \"123\",\n            guild_id: \"456\",\n            user_id: \"789\",\n            session_id: \"abc\"\n          }\n\n          Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})\n\n          assert Agent.get(recorder, & &1) == []\n        end\n      end)\n    end\n\n    test \"plays join sounds immediately without artificial delay\" do\n      user = insert_user!(%{discord_id: \"555\", username: \"joiner\"})\n      sound = insert_sound!(user, %{filename: \"join.mp3\"})\n      insert_user_sound_setting!(user, sound, %{is_join_sound: true})\n\n      bot_id = \"999\"\n      guild_id = \"456\"\n      channel_id = \"123\"\n\n      guild = %{\n        id: guild_id,\n        voice_states: [\n          %{user_id: bot_id, channel_id: channel_id, guild_id: guild_id, session_id: \"bot\"},\n          %{\n            user_id: user.discord_id,\n            channel_id: channel_id,\n            guild_id: guild_id,\n            session_id: \"abc\"\n          }\n        ]\n      }\n\n      {:ok, recorder} = Agent.start_link(fn -> [] end)\n\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.Discord.GuildCache, [], [all: fn -> [guild] end]},\n          {Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: bot_id}} end]},\n          {Soundboard.AudioPlayer, [],\n           [\n             play_sound: fn filename, played_by ->\n               Agent.update(recorder, &(&1 ++ [{:play_sound, filename, played_by}]))\n               :ok\n             end\n           ]}\n        ]) do\n          payload = %{\n            channel_id: channel_id,\n            guild_id: guild_id,\n            user_id: user.discord_id,\n            session_id: \"abc\"\n          }\n\n          Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})\n\n          assert Agent.get(recorder, & &1) == [{:play_sound, \"join.mp3\", \"System\"}]\n        end\n      end)\n    end\n\n    test \"plays leave sounds before auto-leaving the voice channel\" do\n      user = insert_user!(%{discord_id: \"556\", username: \"leaver\"})\n      sound = insert_sound!(user, %{filename: \"leave.mp3\"})\n      insert_user_sound_setting!(user, sound, %{is_leave_sound: true})\n\n      bot_id = \"999\"\n      guild_id = \"456\"\n      channel_id = \"123\"\n\n      guild = %{\n        id: guild_id,\n        voice_states: [\n          %{user_id: bot_id, channel_id: channel_id, guild_id: guild_id, session_id: \"bot\"}\n        ]\n      }\n\n      {:ok, recorder} = Agent.start_link(fn -> [] end)\n\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.Discord.GuildCache, [],\n           [\n             all: fn -> [guild] end,\n             get: fn ^guild_id -> {:ok, guild} end\n           ]},\n          {Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: bot_id}} end]},\n          {Soundboard.AudioPlayer, [],\n           [\n             play_sound: fn filename, played_by ->\n               Agent.update(recorder, &(&1 ++ [{:play_sound, filename, played_by}]))\n               :ok\n             end,\n             last_user_left: fn guild ->\n               Agent.update(recorder, &(&1 ++ [{:last_user_left, guild}]))\n               :ok\n             end\n           ]}\n        ]) do\n          payload = %{\n            channel_id: nil,\n            guild_id: guild_id,\n            user_id: user.discord_id,\n            session_id: \"gone\"\n          }\n\n          Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})\n\n          assert Agent.get(recorder, & &1) == [\n                   {:play_sound, \"leave.mp3\", \"System\"},\n                   {:last_user_left, guild_id}\n                 ]\n        end\n      end)\n    end\n\n    test \"schedules runtime follow-up messages from the handler boundary\" do\n      payload = %{channel_id: \"123\", guild_id: \"456\", user_id: \"999\", session_id: \"abc\"}\n\n      with_mocks([\n        {Soundboard.Discord.Handler.VoiceRuntime, [],\n         [\n           bot_user?: fn _ -> true end,\n           handle_connect: fn ^payload ->\n             [{:schedule_recheck_alone, \"456\", \"123\", 0}]\n           end\n         ]}\n      ]) do\n        assert {:noreply, nil} =\n                 Handler.handle_cast({:eda_event, {:VOICE_STATE_UPDATE, payload, nil}}, nil)\n\n        assert_receive {:recheck_alone, \"456\", \"123\"}\n      end\n    end\n\n    test \"voice commands update the audio player once after the Discord call succeeds\" do\n      guild_id = \"456\"\n      channel_id = \"123\"\n      user_id = \"777\"\n\n      guild = %{\n        id: guild_id,\n        voice_states: [\n          %{user_id: user_id, channel_id: channel_id, guild_id: guild_id, session_id: \"voice\"}\n        ]\n      }\n\n      {:ok, recorder} = Agent.start_link(fn -> [] end)\n\n      capture_log(fn ->\n        with_mocks([\n          {Soundboard.Discord.GuildCache, [],\n           [get!: fn ^guild_id -> guild end, get: fn ^guild_id -> {:ok, guild} end]},\n          {Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: \"999\"}} end]},\n          {Soundboard.Discord.Message, [], [create: fn _, _ -> :ok end]},\n          {Soundboard.Discord.Voice, [],\n           [\n             join_channel: fn ^guild_id, ^channel_id ->\n               Agent.update(recorder, &(&1 ++ [{:join_channel, guild_id, channel_id}]))\n               :ok\n             end,\n             leave_channel: fn ^guild_id ->\n               Agent.update(recorder, &(&1 ++ [{:leave_channel, guild_id}]))\n               :ok\n             end\n           ]},\n          {Soundboard.AudioPlayer, [],\n           [\n             set_voice_channel: fn guild, channel ->\n               Agent.update(recorder, &(&1 ++ [{:set_voice_channel, guild, channel}]))\n               :ok\n             end\n           ]}\n        ]) do\n          Handler.handle_event({\n            :MESSAGE_CREATE,\n            %{content: \"!join\", guild_id: guild_id, channel_id: \"text\", author: %{id: user_id}},\n            nil\n          })\n\n          Handler.handle_event({\n            :MESSAGE_CREATE,\n            %{content: \"!leave\", guild_id: guild_id, channel_id: \"text\", author: %{id: user_id}},\n            nil\n          })\n\n          assert Agent.get(recorder, & &1) == [\n                   {:join_channel, guild_id, channel_id},\n                   {:set_voice_channel, guild_id, channel_id},\n                   {:leave_channel, guild_id},\n                   {:set_voice_channel, nil, nil}\n                 ]\n        end\n      end)\n    end\n  end\n\n  defp insert_user!(attrs) do\n    %User{}\n    |> User.changeset(Map.put_new(attrs, :avatar, \"avatar.png\"))\n    |> Repo.insert!()\n  end\n\n  defp insert_sound!(user, attrs) do\n    attrs =\n      attrs\n      |> Map.put_new(:user_id, user.id)\n      |> Map.put_new(:source_type, \"local\")\n      |> Map.put_new(:volume, 1.0)\n\n    %Sound{}\n    |> Sound.changeset(attrs)\n    |> Repo.insert!()\n  end\n\n  defp insert_user_sound_setting!(user, sound, attrs) do\n    attrs =\n      attrs\n      |> Map.put(:user_id, user.id)\n      |> Map.put(:sound_id, sound.id)\n\n    %UserSoundSetting{}\n    |> UserSoundSetting.changeset(attrs)\n    |> Repo.insert!()\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/eda_consumer_test.exs",
    "content": "defmodule Soundboard.Discord.ConsumerTest do\n  use ExUnit.Case, async: false\n\n  alias Soundboard.Discord.{Consumer, Handler}\n\n  setup do\n    on_exit(fn ->\n      if Process.whereis(Handler) == self() do\n        Process.unregister(Handler)\n      end\n    end)\n\n    :ok\n  end\n\n  test \"dispatches events through the DiscordHandler GenServer boundary\" do\n    Process.register(self(), Handler)\n\n    assert :ok = Consumer.handle_event({:READY, %{id: \"1\"}})\n\n    assert_receive {:\"$gen_cast\", {:eda_event, {:READY, %{id: \"1\"}, nil}}}\n  end\n\n  test \"returns error when the DiscordHandler is unavailable\" do\n    refute Process.whereis(Handler)\n\n    assert :error = Consumer.handle_event({:READY, %{id: \"1\"}})\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/favorites_live_test.exs",
    "content": "defmodule SoundboardWeb.FavoritesLiveTest do\n  use SoundboardWeb.ConnCase\n\n  import Phoenix.LiveViewTest\n  import Mock\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Favorites, Repo, Sound}\n  alias SoundboardWeb.SoundHelpers\n\n  setup %{conn: conn} do\n    Repo.delete_all(Sound)\n    Repo.delete_all(User)\n\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"favorite_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"favorite.jpg\"\n      })\n      |> Repo.insert()\n\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"favorite_#{System.unique_integer([:positive])}.mp3\",\n        user_id: user.id,\n        source_type: \"local\"\n      })\n      |> Repo.insert()\n\n    {:ok, second_sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"favorite_extra_#{System.unique_integer([:positive])}.mp3\",\n        user_id: user.id,\n        source_type: \"local\"\n      })\n      |> Repo.insert()\n\n    {:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)\n\n    authed_conn =\n      conn\n      |> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))\n      |> init_test_session(%{user_id: user.id})\n\n    %{conn: authed_conn, user: user, sound: sound, second_sound: second_sound}\n  end\n\n  test \"redirects unauthenticated users\", _context do\n    conn =\n      build_conn()\n      |> get(\"/favorites\")\n\n    assert redirected_to(conn) == \"/auth/discord\"\n  end\n\n  test \"renders favorite sounds for the current user\", %{conn: conn, sound: sound} do\n    {:ok, _view, html} = live(conn, \"/favorites\")\n\n    assert html =~ \"Favorites\"\n    assert html =~ SoundHelpers.display_name(sound.filename)\n  end\n\n  test \"plays a favorite sound\", %{conn: conn, user: user, sound: sound} do\n    {:ok, view, _html} = live(conn, \"/favorites\")\n\n    with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n      view\n      |> element(\"[phx-click='play'][phx-value-name='#{sound.filename}']\")\n      |> render_click()\n\n      assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))\n    end\n  end\n\n  test \"toggle_favorite removes a sound from favorites\", %{conn: conn, sound: sound} do\n    {:ok, view, _html} = live(conn, \"/favorites\")\n\n    html =\n      view\n      |> element(\"[phx-click='toggle_favorite'][phx-value-sound-id='#{sound.id}']\")\n      |> render_click()\n\n    assert html =~ \"Favorites updated!\"\n    assert html =~ \"You currently have no favorites\"\n  end\n\n  test \"files_updated refreshes the favorites list\", %{\n    conn: conn,\n    user: user,\n    second_sound: second_sound\n  } do\n    {:ok, view, _html} = live(conn, \"/favorites\")\n\n    {:ok, _favorite} = Favorites.toggle_favorite(user.id, second_sound.id)\n    send(view.pid, {:files_updated})\n\n    assert render(view) =~ SoundHelpers.display_name(second_sound.filename)\n  end\n\n  test \"stats_updated refreshes the favorites list\", %{\n    conn: conn,\n    user: user,\n    second_sound: second_sound\n  } do\n    {:ok, view, _html} = live(conn, \"/favorites\")\n\n    {:ok, _favorite} = Favorites.toggle_favorite(user.id, second_sound.id)\n    send(view.pid, {:stats_updated})\n\n    assert render(view) =~ SoundHelpers.display_name(second_sound.filename)\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/settings_live_test.exs",
    "content": "defmodule SoundboardWeb.SettingsLiveTest do\n  use SoundboardWeb.ConnCase\n  import Phoenix.LiveViewTest\n  alias Soundboard.Accounts.{ApiTokens, User}\n  alias Soundboard.Repo\n\n  setup %{conn: conn} do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"apitok_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    authed_conn =\n      conn\n      |> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))\n      |> init_test_session(%{user_id: user.id})\n\n    %{conn: authed_conn, user: user}\n  end\n\n  test \"can create and revoke tokens via live view\", %{conn: conn} do\n    {:ok, view, _html} = live(conn, \"/settings\")\n\n    # Create token\n    view\n    |> element(\"form[phx-submit=\\\"create_token\\\"]\")\n    |> render_submit(%{\"label\" => \"CI Bot\"})\n\n    # Ensure it appears in the table\n    html = render(view)\n    assert html =~ \"CI Bot\"\n\n    # Revoke the first token button\n    view\n    |> element(\"button\", \"Revoke\")\n    |> render_click()\n\n    # Should disappear from the table\n    refute has_element?(view, \"td\", \"CI Bot\")\n  end\n\n  test \"shows persisted tokens after reload\", %{conn: conn, user: user} do\n    {:ok, raw, _token} = ApiTokens.generate_token(user, %{label: \"Saved token\"})\n\n    {:ok, _view, html} = live(conn, \"/settings\")\n\n    assert html =~ \"Saved token\"\n    assert html =~ raw\n  end\n\n  test \"shows upload API documentation\", %{conn: conn} do\n    {:ok, _view, html} = live(conn, \"/settings\")\n\n    assert html =~ \"POST /api/sounds\"\n    assert html =~ \"Upload local file (multipart/form-data)\"\n    assert html =~ \"Upload from URL (JSON)\"\n    assert html =~ \"tags[]\"\n    assert html =~ \"is_join_sound\"\n    assert html =~ \"is_leave_sound\"\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live/edit_flow_test.exs",
    "content": "defmodule SoundboardWeb.Live.SoundboardLive.EditFlowTest do\n  use Soundboard.DataCase, async: true\n\n  alias Phoenix.LiveView.Socket\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Sound, Tag}\n  alias SoundboardWeb.Live.SoundboardLive.EditFlow\n\n  test \"select_tag adds a suggested tag even when it is outside the empty-search limit\" do\n    seed_alphabetical_tags()\n    tag = Repo.insert!(Tag.changeset(%Tag{}, %{name: \"meme\"}))\n    user = create_user()\n\n    sound =\n      %Sound{}\n      |> Sound.changeset(%{filename: \"test.mp3\", source_type: \"local\", user_id: user.id})\n      |> Repo.insert!()\n      |> Repo.preload(:tags)\n\n    socket = %Socket{assigns: %{__changed__: %{}, current_sound: sound}}\n\n    assert {:noreply, updated_socket} = EditFlow.select_tag(socket, \"meme\")\n\n    assert Enum.any?(updated_socket.assigns.current_sound.tags, &(&1.id == tag.id))\n    assert updated_socket.assigns.tag_input == \"\"\n    assert updated_socket.assigns.tag_suggestions == []\n  end\n\n  defp create_user do\n    %User{}\n    |> User.changeset(%{\n      username: \"testuser\",\n      discord_id: Integer.to_string(System.unique_integer([:positive])),\n      avatar: \"avatar.png\"\n    })\n    |> Repo.insert!()\n  end\n\n  defp seed_alphabetical_tags do\n    for name <- ~w(a b c d e f g h i j) do\n      Repo.insert!(Tag.changeset(%Tag{}, %{name: name}))\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live/upload_flow_test.exs",
    "content": "defmodule SoundboardWeb.Live.SoundboardLive.UploadFlowTest do\n  use Soundboard.DataCase, async: true\n\n  alias Phoenix.LiveView.Socket\n  alias Soundboard.Tag\n  alias SoundboardWeb.Live.SoundboardLive.UploadFlow\n\n  test \"select_tag adds a suggested tag even when it is outside the empty-search limit\" do\n    seed_alphabetical_tags()\n    Repo.insert!(Tag.changeset(%Tag{}, %{name: \"meme\"}))\n\n    socket = build_socket(%{current_sound: nil, upload_tags: []})\n\n    assert {:noreply, updated_socket} = UploadFlow.select_tag(socket, \"meme\")\n\n    assert Enum.map(updated_socket.assigns.upload_tags, & &1.name) == [\"meme\"]\n    assert updated_socket.assigns.upload_tag_input == \"\"\n    assert updated_socket.assigns.upload_tag_suggestions == []\n  end\n\n  test \"save treats consume_uploaded_entries success results as a successful upload\" do\n    socket = build_socket(%{show_upload_modal: true})\n\n    consume_uploaded_entries_fn = fn _socket, :audio, _fun ->\n      [{:ok, %{id: 123}}]\n    end\n\n    assert {:noreply, updated_socket} = UploadFlow.save(socket, %{}, consume_uploaded_entries_fn)\n\n    assert updated_socket.assigns.show_upload_modal == false\n    assert updated_socket.assigns.flash[\"info\"] == \"Sound added successfully\"\n    assert is_list(updated_socket.assigns.uploaded_files)\n  end\n\n  test \"save shows upload errors returned by consume_uploaded_entries\" do\n    socket = build_socket(%{show_upload_modal: true})\n\n    changeset =\n      %Ecto.Changeset{}\n      |> Ecto.Changeset.change()\n      |> Ecto.Changeset.add_error(:filename, \"can't be blank\")\n\n    consume_uploaded_entries_fn = fn _socket, :audio, _fun ->\n      [{:error, changeset}]\n    end\n\n    assert {:noreply, updated_socket} = UploadFlow.save(socket, %{}, consume_uploaded_entries_fn)\n\n    assert updated_socket.assigns.show_upload_modal == true\n    assert updated_socket.assigns.flash[\"error\"] == \"filename can't be blank\"\n  end\n\n  defp build_socket(overrides) do\n    %Socket{\n      assigns:\n        Map.merge(\n          %{\n            __changed__: %{},\n            flash: %{},\n            current_sound: nil,\n            show_upload_modal: false,\n            source_type: \"local\",\n            upload_name: \"\",\n            url: \"\",\n            upload_tags: [],\n            upload_tag_input: \"\",\n            upload_tag_suggestions: [],\n            is_join_sound: false,\n            is_leave_sound: false,\n            upload_error: nil,\n            upload_volume: 100\n          },\n          overrides\n        ),\n      private: %{live_temp: %{flash: %{}}}\n    }\n  end\n\n  defp seed_alphabetical_tags do\n    for name <- ~w(a b c d e f g h i j) do\n      Repo.insert!(Tag.changeset(%Tag{}, %{name: name}))\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/soundboard_live_test.exs",
    "content": "defmodule SoundboardWeb.SoundboardLiveTest do\n  @moduledoc false\n  use SoundboardWeb.ConnCase\n  import Phoenix.LiveViewTest\n  alias Soundboard.{Accounts.User, Repo, Sound, Tag}\n  import Mock\n\n  setup %{conn: conn} do\n    Repo.delete_all(Sound)\n    Repo.delete_all(User)\n\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser\",\n        discord_id: \"123\",\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      })\n      |> Repo.insert()\n\n    conn = conn |> init_test_session(%{user_id: user.id})\n\n    {:ok, conn: conn, user: user, sound: sound}\n  end\n\n  describe \"Soundboard LiveView\" do\n    test \"mounts successfully with user session\", %{conn: conn} do\n      {:ok, _, html} = live(conn, \"/\")\n\n      assert html =~ \"Soundboard\"\n      # Check for the main content instead of a specific container\n      assert html =~ \"SoundBored\"\n    end\n\n    test \"can search sounds\", %{conn: conn} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"form\")\n      |> render_change(%{\"query\" => \"test\"})\n\n      rendered = render(view)\n      assert rendered =~ \"test.mp3\"\n    end\n\n    test \"can play sound\", %{conn: conn, sound: sound} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n        rendered =\n          view\n          |> element(\"[phx-click='play'][phx-value-name='#{sound.filename}']\")\n          |> render_click()\n\n        assert rendered =~ sound.filename\n      end\n    end\n\n    test \"play random respects current search results\", %{conn: conn, user: user} do\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"filtered.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      })\n      |> Repo.insert!()\n\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"form\")\n      |> render_change(%{\"query\" => \"filtered\"})\n\n      with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n        view\n        |> element(\"[phx-click='play_random']\")\n        |> render_click()\n\n        assert_called(Soundboard.AudioPlayer.play_sound(\"filtered.mp3\", :_))\n      end\n    end\n\n    test \"play random respects selected tags\", %{conn: conn, user: user} do\n      tag =\n        %Tag{}\n        |> Tag.changeset(%{name: \"funny\"})\n        |> Repo.insert!()\n\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"funny.mp3\",\n        source_type: \"local\",\n        user_id: user.id,\n        tags: [tag]\n      })\n      |> Repo.insert!()\n\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"div.hidden.sm\\\\:flex button[phx-value-tag='funny']\")\n      |> render_click()\n\n      with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n        view\n        |> element(\"[phx-click='play_random']\")\n        |> render_click()\n\n        assert_called(Soundboard.AudioPlayer.play_sound(\"funny.mp3\", :_))\n      end\n    end\n\n    test \"can open and close upload modal\", %{conn: conn} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      # First verify we can see the Add Sound button\n      assert render(view) =~ \"Add Sound\"\n\n      # Click the Add Sound button and verify modal appears\n      view\n      |> element(\"[phx-click='show_upload_modal']\")\n      |> render_click()\n\n      # The modal should be visible now, verify its presence using form ID and content\n      assert has_element?(view, \"#upload-form\")\n      assert has_element?(view, \"form[phx-submit='save_upload']\")\n      assert has_element?(view, \"select[name='source_type']\")\n      assert render(view) =~ \"Source Type\"\n\n      # Close the modal using the correct phx-click value\n      view\n      |> element(\"[phx-click='close_upload_modal']\")\n      |> render_click()\n\n      # Verify modal is gone by checking for the form\n      refute has_element?(view, \"#upload-form\")\n    end\n\n    test \"can edit sound\", %{conn: conn, sound: sound} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      rendered =\n        view\n        |> element(\"[phx-click='edit'][phx-value-id='#{sound.id}']\")\n        |> render_click()\n\n      assert rendered =~ \"Edit Sound\"\n\n      params = %{\n        \"filename\" => \"updated\",\n        \"source_type\" => \"local\",\n        \"volume\" => \"80\"\n      }\n\n      uploads_dir = uploads_dir()\n      File.mkdir_p!(uploads_dir)\n\n      test_file = Path.join(uploads_dir, \"test.mp3\")\n      updated_file = Path.join(uploads_dir, \"updated.mp3\")\n\n      unless File.exists?(test_file) do\n        File.write!(test_file, \"test content\")\n      end\n\n      # Target the edit form specifically\n      view\n      |> element(\"#edit-form\")\n      |> render_submit(params)\n\n      # Clean up both original and updated files\n      File.rm_rf!(test_file)\n      File.rm_rf!(updated_file)\n\n      updated_sound = Repo.get(Sound, sound.id)\n      assert updated_sound.filename == \"updated.mp3\"\n      assert_in_delta updated_sound.volume, 0.8, 0.0001\n    end\n\n    test \"shared sounds can be edited by any signed-in user but only deleted by the uploader\", %{\n      conn: conn\n    } do\n      {:ok, other_user} =\n        %User{}\n        |> User.changeset(%{\n          username: \"other_#{System.unique_integer([:positive])}\",\n          discord_id: Integer.to_string(System.unique_integer([:positive])),\n          avatar: \"other.jpg\"\n        })\n        |> Repo.insert()\n\n      {:ok, other_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"other-owned.mp3\",\n          source_type: \"local\",\n          user_id: other_user.id\n        })\n        |> Repo.insert()\n\n      {:ok, view, _html} = live(conn, \"/\")\n\n      assert has_element?(view, \"[phx-click='edit'][phx-value-id='#{other_sound.id}']\")\n\n      rendered =\n        view\n        |> element(\"[phx-click='edit'][phx-value-id='#{other_sound.id}']\")\n        |> render_click()\n\n      assert rendered =~ \"Edit Sound\"\n      refute rendered =~ \"Delete Sound\"\n    end\n\n    test \"edit validation preserves the current sound extension when checking duplicates\", %{\n      conn: conn,\n      user: user\n    } do\n      {:ok, current_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"current.wav\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      {:ok, _existing_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"taken.wav\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='edit'][phx-value-id='#{current_sound.id}']\")\n      |> render_click()\n\n      view\n      |> element(\"#edit-form\")\n      |> render_change(%{\n        \"_target\" => [\"filename\"],\n        \"sound_id\" => current_sound.id,\n        \"filename\" => \"taken\"\n      })\n\n      assert render(view) =~ \"A sound with that name already exists\"\n    end\n\n    test \"slider volume change persists on save\", %{conn: conn, sound: sound} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='edit'][phx-value-id='#{sound.id}']\")\n      |> render_click()\n\n      render_hook(view, :update_volume, %{\"volume\" => 27, \"target\" => \"edit\"})\n\n      base_filename = Path.rootname(sound.filename)\n\n      view\n      |> element(\"#edit-form\")\n      |> render_submit(%{\n        \"filename\" => base_filename,\n        \"source_type\" => sound.source_type,\n        \"volume\" => \"27\"\n      })\n\n      updated_sound = Repo.get!(Sound, sound.id)\n      assert_in_delta updated_sound.volume, 0.27, 0.0001\n    end\n\n    test \"can delete sound\", %{conn: conn, sound: sound} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      uploads_dir = uploads_dir()\n      test_file = Path.join(uploads_dir, \"test.mp3\")\n      File.mkdir_p!(uploads_dir)\n      File.write!(test_file, \"test content\")\n\n      view\n      |> element(\"[phx-click='edit'][phx-value-id='#{sound.id}']\")\n      |> render_click()\n\n      view\n      |> element(\"[phx-click='show_delete_confirm']\")\n      |> render_click()\n\n      view\n      |> element(\"[phx-click='delete_sound']\")\n      |> render_click()\n\n      File.rm_rf!(test_file)\n\n      assert Repo.get(Sound, sound.id) == nil\n    end\n\n    test \"url upload allows setting url before name\", %{conn: conn} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='show_upload_modal']\")\n      |> render_click()\n\n      view\n      |> element(\"select[name='source_type']\")\n      |> render_change(%{\"source_type\" => \"url\"})\n\n      html =\n        view\n        |> element(\"#upload-form\")\n        |> render_change(%{\"url\" => \"https://example.com/beep.mp3\"})\n\n      refute html =~ \"Please select a file\"\n      refute html =~ \"can't be blank\"\n      assert html =~ \"https://example.com/beep.mp3\"\n    end\n\n    test \"can upload sound from url\", %{conn: conn, user: user} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='show_upload_modal']\")\n      |> render_click()\n\n      view\n      |> element(\"select[name='source_type']\")\n      |> render_change(%{\"source_type\" => \"url\"})\n\n      params = %{\n        \"url\" => \"https://example.com/wow.mp3\",\n        \"name\" => \"wow\"\n      }\n\n      view\n      |> element(\"#upload-form\")\n      |> render_submit(params)\n\n      new_sound = Repo.get_by!(Sound, filename: \"wow.mp3\")\n      assert new_sound.source_type == \"url\"\n      assert new_sound.url == \"https://example.com/wow.mp3\"\n      assert new_sound.user_id == user.id\n\n      Repo.delete!(new_sound)\n    end\n\n    test \"upload sound from url saves provided volume\", %{conn: conn} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='show_upload_modal']\")\n      |> render_click()\n\n      view\n      |> element(\"select[name='source_type']\")\n      |> render_change(%{\"source_type\" => \"url\"})\n\n      view\n      |> element(\"#upload-form\")\n      |> render_submit(%{\n        \"url\" => \"https://example.com/soft.mp3\",\n        \"name\" => \"soft\",\n        \"volume\" => \"25\"\n      })\n\n      sound = Repo.get_by!(Sound, filename: \"soft.mp3\")\n      assert_in_delta sound.volume, 0.25, 0.0001\n\n      Repo.delete!(sound)\n    end\n\n    test \"deleting a local sound removes the file\", %{conn: conn, sound: sound} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      uploads_dir = uploads_dir()\n      File.mkdir_p!(uploads_dir)\n      sound_path = Path.join(uploads_dir, sound.filename)\n      File.write!(sound_path, \"test content\")\n\n      view\n      |> element(\"[phx-click='edit'][phx-value-id='#{sound.id}']\")\n      |> render_click()\n\n      view\n      |> element(\"[phx-click='show_delete_confirm']\")\n      |> render_click()\n\n      view\n      |> element(\"[phx-click='delete_sound']\")\n      |> render_click()\n\n      refute File.exists?(sound_path)\n      assert Repo.get(Sound, sound.id) == nil\n    end\n\n    test \"failed rename keeps original file\", %{conn: conn, user: user, sound: sound} do\n      {:ok, conflict_sound} =\n        %Sound{}\n        |> Sound.changeset(%{\n          filename: \"conflict.mp3\",\n          source_type: \"local\",\n          user_id: user.id\n        })\n        |> Repo.insert()\n\n      uploads_dir = uploads_dir()\n      File.mkdir_p!(uploads_dir)\n\n      original_path = Path.join(uploads_dir, sound.filename)\n      conflict_path = Path.join(uploads_dir, conflict_sound.filename)\n\n      File.write!(original_path, \"original\")\n      File.rm_rf!(conflict_path)\n\n      on_exit(fn ->\n        uploads_dir = uploads_dir()\n        File.rm_rf!(Path.join(uploads_dir, sound.filename))\n        File.rm_rf!(Path.join(uploads_dir, \"conflict.mp3\"))\n      end)\n\n      {:ok, view, _html} = live(conn, \"/\")\n\n      view\n      |> element(\"[phx-click='edit'][phx-value-id='#{sound.id}']\")\n      |> render_click()\n\n      _html =\n        view\n        |> element(\"#edit-form\")\n        |> render_submit(%{\n          \"filename\" => \"conflict\",\n          \"source_type\" => \"local\",\n          \"url\" => \"\",\n          \"sound_id\" => Integer.to_string(sound.id)\n        })\n\n      assert File.exists?(original_path)\n      refute File.exists?(conflict_path)\n      assert Repo.get!(Sound, sound.id).filename == sound.filename\n    end\n\n    test \"handles pubsub updates\", %{conn: conn} do\n      {:ok, view, _html} = live(conn, \"/\")\n\n      Soundboard.PubSubTopics.broadcast_files_updated()\n\n      # Just verify the view is still alive\n      assert render(view) =~ \"SoundBored\"\n    end\n  end\n\n  defp uploads_dir do\n    Soundboard.UploadsPath.dir()\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/live/stats_live_test.exs",
    "content": "defmodule SoundboardWeb.StatsLiveTest do\n  @moduledoc \"\"\"\n  Test for the StatsLive component.\n  \"\"\"\n  use SoundboardWeb.ConnCase\n  import Phoenix.LiveViewTest\n  alias Soundboard.Accounts.User\n  alias Soundboard.{Favorites, Repo, Sound, Stats}\n  alias Soundboard.Stats.Play\n  alias SoundboardWeb.SoundHelpers\n  import Mock\n\n  setup %{conn: conn} do\n    Repo.delete_all(Play)\n    Repo.delete_all(Sound)\n    Repo.delete_all(User)\n\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser\",\n        discord_id: \"123\",\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test_sound_#{System.unique_integer()}.mp3\",\n        user_id: user.id,\n        source_type: \"local\"\n      })\n      |> Repo.insert()\n\n    {:ok, _play} = Stats.track_play(sound.filename, user.id)\n    Favorites.toggle_favorite(user.id, sound.id)\n\n    authed_conn =\n      conn\n      |> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))\n      |> init_test_session(%{user_id: user.id})\n\n    {:ok, conn: authed_conn, user: user, sound: sound}\n  end\n\n  test \"mounts successfully with user session\", %{conn: conn, user: user, sound: sound} do\n    {:ok, _view, html} = live(conn, \"/stats\")\n    assert html =~ \"Stats\"\n    assert html =~ \"Top Users\"\n    assert html =~ user.username\n    assert html =~ SoundHelpers.display_name(sound.filename)\n  end\n\n  test \"handles sound_played message\", %{conn: conn, sound: sound} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    send(view.pid, {:sound_played, %{filename: sound.filename, played_by: \"testuser\"}})\n    assert render(view) =~ \"testuser played #{SoundHelpers.display_name(sound.filename)}\"\n  end\n\n  test \"handles stats_updated message\", %{conn: conn, sound: sound} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    send(view.pid, {:stats_updated})\n    assert render(view) =~ SoundHelpers.display_name(sound.filename)\n  end\n\n  test \"renders renamed sounds from historical plays\", %{conn: conn, sound: sound} do\n    renamed = \"renamed_#{System.unique_integer([:positive])}.mp3\"\n\n    sound\n    |> Sound.changeset(%{filename: renamed})\n    |> Repo.update!()\n\n    {:ok, _view, html} = live(conn, \"/stats\")\n\n    assert html =~ SoundHelpers.display_name(renamed)\n  end\n\n  test \"handles error message\", %{conn: conn} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    send(view.pid, {:error, \"Test error\"})\n    assert render(view) =~ \"Test error\"\n  end\n\n  test \"handles presence_diff broadcast\", %{conn: conn} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    send(view.pid, %Phoenix.Socket.Broadcast{\n      event: \"presence_diff\",\n      payload: %{joins: %{}, leaves: %{}}\n    })\n\n    assert render(view) =~ \"Top Users\"\n  end\n\n  test \"handles play_sound event\", %{conn: conn, user: user, sound: sound} do\n    {:ok, view, _html} = live_as_user(conn, user)\n\n    with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n      html = render_click(view, \"play_sound\", %{\"sound\" => sound.filename})\n      assert html =~ SoundHelpers.display_name(sound.filename)\n    end\n  end\n\n  test \"handles toggle_favorite event\", %{conn: conn, user: user, sound: sound} do\n    {:ok, view, _html} = live_as_user(conn, user)\n\n    html = render_click(view, \"toggle_favorite\", %{\"sound\" => sound.filename})\n    assert html =~ SoundHelpers.display_name(sound.filename)\n  end\n\n  test \"handles week navigation\", %{conn: conn} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    html = render_click(view, \"previous_week\")\n    assert html =~ \"Stats\"\n\n    html = render_click(view, \"next_week\")\n    assert html =~ \"Stats\"\n  end\n\n  test \"handles week picker input\", %{conn: conn} do\n    {:ok, view, _html} = live(conn, \"/stats\")\n\n    target_date = Date.add(Date.utc_today(), -7)\n    days_since_monday = Date.day_of_week(target_date, :monday) - 1\n    start_date = Date.add(target_date, -days_since_monday)\n    week_value = Date.to_iso8601(target_date)\n\n    view\n    |> element(\"form[phx-change=\\\"select_week\\\"]\")\n    |> render_change(%{\"week\" => week_value})\n\n    html = render(view)\n    assert html =~ Calendar.strftime(start_date, \"%b %d\")\n  end\n\n  defp live_as_user(conn, user) do\n    conn\n    |> Plug.Test.init_test_session(%{})\n    |> put_session(:user_id, user.id)\n    |> live(\"/stats\")\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/plugs/api_auth_db_token_test.exs",
    "content": "defmodule SoundboardWeb.APIAuthDBTokenTest do\n  use SoundboardWeb.ConnCase\n  import Phoenix.ConnTest\n  import Mock\n\n  alias Soundboard.Accounts.{ApiTokens, User}\n  alias Soundboard.{Repo, Sound}\n\n  setup %{conn: conn} do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"apitok_user_#{System.unique_integer([:positive])}\",\n        discord_id: Integer.to_string(System.unique_integer([:positive])),\n        avatar: \"test.jpg\"\n      })\n      |> Repo.insert()\n\n    {:ok, raw, _rec} = ApiTokens.generate_token(user, %{label: \"test\"})\n\n    {:ok, sound} =\n      %Sound{}\n      |> Sound.changeset(%{\n        filename: \"test_sound_#{System.unique_integer([:positive])}.mp3\",\n        source_type: \"local\",\n        user_id: user.id\n      })\n      |> Repo.insert()\n\n    conn = put_req_header(conn, \"authorization\", \"Bearer \" <> raw)\n    %{conn: conn, user: user, sound: sound}\n  end\n\n  test \"GET /api/sounds authorized via DB token\", %{conn: conn} do\n    conn = get(conn, ~p\"/api/sounds\")\n    assert json_response(conn, 200)[\"data\"] |> is_list()\n  end\n\n  test \"POST /api/sounds/:id/play authorized via DB token\", %{\n    conn: conn,\n    sound: sound,\n    user: user\n  } do\n    # Mock the audio player so we don't actually attempt voice playback\n    with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do\n      conn = post(conn, ~p\"/api/sounds/#{sound.id}/play\")\n\n      assert %{\n               \"data\" => %{\n                 \"status\" => \"accepted\",\n                 \"sound\" => %{\"id\" => sound_id, \"filename\" => filename}\n               }\n             } = json_response(conn, 202)\n\n      assert sound_id == sound.id\n      assert filename == sound.filename\n      assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))\n    end\n  end\n\n  test \"POST /api/sounds/stop authorized via DB token\", %{conn: conn} do\n    with_mock Soundboard.AudioPlayer, stop_sound: fn -> :ok end do\n      conn = post(conn, ~p\"/api/sounds/stop\")\n      assert %{\"data\" => %{\"status\" => \"accepted\"}} = json_response(conn, 202)\n    end\n  end\n\n  test \"unauthorized when token invalid\", %{conn: _conn} do\n    conn = build_conn() |> put_req_header(\"authorization\", \"Bearer badtoken\")\n    conn = get(conn, ~p\"/api/sounds\")\n    assert json_response(conn, 401)\n  end\n\n  test \"returns internal server error when token bookkeeping fails\", %{conn: _conn} do\n    with_mock Soundboard.Accounts.ApiTokens,\n      verify_token: fn _ -> {:error, :token_update_failed} end do\n      conn =\n        build_conn()\n        |> put_req_header(\"authorization\", \"Bearer anytoken\")\n        |> get(~p\"/api/sounds\")\n\n      assert %{\"error\" => \"API token verification failed\"} = json_response(conn, 500)\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/plugs/basic_auth_test.exs",
    "content": "defmodule SoundboardWeb.BasicAuthPlugTest do\n  use ExUnit.Case, async: true\n  import Plug.Test\n  import Plug.Conn\n\n  alias SoundboardWeb.Plugs.BasicAuth\n\n  setup do\n    previous_username = System.get_env(\"BASIC_AUTH_USERNAME\")\n    previous_password = System.get_env(\"BASIC_AUTH_PASSWORD\")\n\n    System.delete_env(\"BASIC_AUTH_USERNAME\")\n    System.delete_env(\"BASIC_AUTH_PASSWORD\")\n\n    on_exit(fn ->\n      restore_env(\"BASIC_AUTH_USERNAME\", previous_username)\n      restore_env(\"BASIC_AUTH_PASSWORD\", previous_password)\n    end)\n\n    :ok\n  end\n\n  defp restore_env(key, nil), do: System.delete_env(key)\n  defp restore_env(key, value), do: System.put_env(key, value)\n\n  # -- No credentials configured: auth disabled --\n\n  test \"bypasses auth when both credentials are missing\" do\n    conn = conn(:get, \"/\") |> BasicAuth.call(%{})\n    refute conn.halted\n  end\n\n  test \"treats blank credentials as missing and bypasses auth\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"  \")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"\")\n\n    conn = conn(:get, \"/\") |> BasicAuth.call(%{})\n    refute conn.halted\n  end\n\n  # -- Partial credentials: fail closed --\n\n  test \"fails closed when only username is configured\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.delete_env(\"BASIC_AUTH_PASSWORD\")\n\n    conn = conn(:get, \"/\") |> BasicAuth.call(%{})\n    assert conn.halted\n    assert conn.status == 401\n  end\n\n  test \"fails closed when only password is configured\" do\n    System.delete_env(\"BASIC_AUTH_USERNAME\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p\")\n\n    conn = conn(:get, \"/\") |> BasicAuth.call(%{})\n    assert conn.halted\n    assert conn.status == 401\n  end\n\n  # -- Both credentials configured: authenticate --\n\n  test \"authorizes with valid Basic header\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p\")\n\n    header = \"Basic \" <> Base.encode64(\"u:p\")\n\n    conn =\n      conn(:get, \"/\")\n      |> put_req_header(\"authorization\", header)\n      |> BasicAuth.call(%{})\n\n    refute conn.halted\n  end\n\n  test \"authorizes when password contains a colon\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p:extra\")\n\n    header = \"Basic \" <> Base.encode64(\"u:p:extra\")\n\n    conn =\n      conn(:get, \"/\")\n      |> put_req_header(\"authorization\", header)\n      |> BasicAuth.call(%{})\n\n    refute conn.halted\n  end\n\n  test \"rejects with 401 when no auth header provided\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p\")\n\n    conn = conn(:get, \"/\") |> BasicAuth.call(%{})\n    assert conn.halted\n    assert conn.status == 401\n    assert get_resp_header(conn, \"www-authenticate\") == [~s(Basic realm=\"Soundboard\")]\n    assert conn.resp_body == \"Unauthorized\"\n  end\n\n  test \"rejects with 401 when credentials are wrong\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p\")\n\n    header = \"Basic \" <> Base.encode64(\"wrong:creds\")\n\n    conn =\n      conn(:get, \"/\")\n      |> put_req_header(\"authorization\", header)\n      |> BasicAuth.call(%{})\n\n    assert conn.halted\n    assert conn.status == 401\n  end\n\n  test \"rejects with 401 when auth header is malformed\" do\n    System.put_env(\"BASIC_AUTH_USERNAME\", \"u\")\n    System.put_env(\"BASIC_AUTH_PASSWORD\", \"p\")\n\n    conn =\n      conn(:get, \"/\")\n      |> put_req_header(\"authorization\", \"Bearer token123\")\n      |> BasicAuth.call(%{})\n\n    assert conn.halted\n    assert conn.status == 401\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/plugs/role_check_test.exs",
    "content": "defmodule SoundboardWeb.Plugs.RoleCheckTest do\n  use SoundboardWeb.ConnCase, async: false\n\n  import Mock\n\n  alias Soundboard.Accounts.User\n  alias Soundboard.Discord.RoleChecker\n  alias Soundboard.Repo\n  alias SoundboardWeb.Plugs.RoleCheck\n\n  setup do\n    previous_guild = Application.get_env(:soundboard, :required_guild_id)\n    previous_roles = Application.get_env(:soundboard, :required_role_ids)\n    previous_interval = Application.get_env(:soundboard, :role_recheck_interval_seconds)\n\n    on_exit(fn ->\n      restore_env(:required_guild_id, previous_guild)\n      restore_env(:required_role_ids, previous_roles)\n      restore_env(:role_recheck_interval_seconds, previous_interval)\n    end)\n\n    {:ok, user: insert_user()}\n  end\n\n  defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)\n  defp restore_env(key, value), do: Application.put_env(:soundboard, key, value)\n\n  defp insert_user do\n    {:ok, user} =\n      %User{}\n      |> User.changeset(%{\n        username: \"testuser#{System.unique_integer([:positive])}\",\n        discord_id: \"discord_#{System.unique_integer([:positive])}\",\n        avatar: \"avatar.jpg\"\n      })\n      |> Repo.insert()\n\n    user\n  end\n\n  defp build_conn_with_session(conn, user, session_params) do\n    conn\n    |> init_test_session(session_params)\n    |> fetch_session()\n    |> fetch_flash()\n    |> assign(:current_user, user)\n  end\n\n  test \"passes through when no current_user is assigned\", %{conn: conn} do\n    Application.put_env(:soundboard, :required_guild_id, \"g1\")\n    Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n\n    with_mock RoleChecker,\n      feature_enabled?: fn -> true end,\n      authorized?: fn _ -> flunk(\"should not be called\") end do\n      result =\n        conn\n        |> init_test_session(%{})\n        |> fetch_session()\n        |> fetch_flash()\n        |> RoleCheck.call(RoleCheck.init([]))\n\n      refute result.halted\n    end\n  end\n\n  describe \"feature disabled\" do\n    test \"passes through without calling authorized? when feature is disabled\", %{\n      conn: conn,\n      user: user\n    } do\n      with_mock RoleChecker,\n        feature_enabled?: fn -> false end,\n        authorized?: fn _ -> flunk(\"should not be called\") end do\n        result =\n          conn\n          |> build_conn_with_session(user, %{user_id: user.id})\n          |> RoleCheck.call(RoleCheck.init([]))\n\n        refute result.halted\n      end\n    end\n  end\n\n  describe \"fresh timestamp\" do\n    setup do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n      :ok\n    end\n\n    test \"passes through without calling authorized? when roles_verified_at is fresh\", %{\n      conn: conn,\n      user: user\n    } do\n      fresh_ts = System.system_time(:second)\n\n      with_mock RoleChecker,\n        feature_enabled?: fn -> true end,\n        authorized?: fn _ -> flunk(\"should not be called\") end do\n        result =\n          conn\n          |> build_conn_with_session(user, %{\n            user_id: user.id,\n            roles_verified_at: fresh_ts\n          })\n          |> RoleCheck.call(RoleCheck.init([]))\n\n        refute result.halted\n      end\n    end\n  end\n\n  describe \"missing timestamp\" do\n    setup do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n      :ok\n    end\n\n    test \"triggers re-check and updates session when authorized and roles_verified_at is absent\",\n         %{conn: conn, user: user} do\n      with_mock RoleChecker,\n        feature_enabled?: fn -> true end,\n        authorized?: fn _discord_id -> true end do\n        result =\n          conn\n          |> build_conn_with_session(user, %{user_id: user.id})\n          |> RoleCheck.call(RoleCheck.init([]))\n\n        refute result.halted\n        assert is_integer(get_session(result, :roles_verified_at))\n        assert_called(RoleChecker.authorized?(user.discord_id))\n      end\n    end\n  end\n\n  describe \"stale timestamp\" do\n    setup do\n      Application.put_env(:soundboard, :required_guild_id, \"g1\")\n      Application.put_env(:soundboard, :required_role_ids, [\"r1\"])\n      :ok\n    end\n\n    test \"triggers re-check and updates session when authorized and roles_verified_at is stale\",\n         %{\n           conn: conn,\n           user: user\n         } do\n      stale_ts = System.system_time(:second) - 999\n\n      with_mock RoleChecker,\n        feature_enabled?: fn -> true end,\n        authorized?: fn _discord_id -> true end do\n        result =\n          conn\n          |> build_conn_with_session(user, %{\n            user_id: user.id,\n            roles_verified_at: stale_ts\n          })\n          |> RoleCheck.call(RoleCheck.init([]))\n\n        refute result.halted\n        new_ts = get_session(result, :roles_verified_at)\n        assert is_integer(new_ts)\n        assert new_ts > stale_ts\n        assert_called(RoleChecker.authorized?(user.discord_id))\n      end\n    end\n\n    test \"clears session, redirects, and halts when unauthorized\", %{conn: conn, user: user} do\n      stale_ts = System.system_time(:second) - 999\n\n      with_mock RoleChecker,\n        feature_enabled?: fn -> true end,\n        authorized?: fn _discord_id -> false end do\n        result =\n          conn\n          |> build_conn_with_session(user, %{\n            user_id: user.id,\n            roles_verified_at: stale_ts\n          })\n          |> RoleCheck.call(RoleCheck.init([]))\n\n        assert result.halted\n        assert redirected_to(result) == \"/\"\n\n        assert Phoenix.Flash.get(result.assigns.flash, :error) == \"Error signing in\"\n\n        refute get_session(result, :user_id)\n        assert_called(RoleChecker.authorized?(user.discord_id))\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/presence_handler_test.exs",
    "content": "defmodule SoundboardWeb.PresenceHandlerTest do\n  use ExUnit.Case, async: false\n\n  import Mock\n\n  alias SoundboardWeb.PresenceHandler\n\n  setup do\n    :persistent_term.put(:user_colors, %{})\n\n    on_exit(fn ->\n      :persistent_term.erase(:user_colors)\n    end)\n\n    :ok\n  end\n\n  test \"start_link/1 returns the named server\" do\n    pid =\n      case PresenceHandler.start_link([]) do\n        {:ok, pid} -> pid\n        {:error, {:already_started, pid}} -> pid\n      end\n\n    assert Process.alive?(pid)\n  end\n\n  test \"init/1 resets the color cache\" do\n    :persistent_term.put(:user_colors, %{\"stale\" => \"color\"})\n\n    assert {:ok, %{}} = PresenceHandler.init(:ok)\n    assert :persistent_term.get(:user_colors) == %{}\n  end\n\n  test \"get_user_color/1 returns stable assignments per user\" do\n    first = PresenceHandler.get_user_color(\"alice\")\n    second = PresenceHandler.get_user_color(\"alice\")\n    third = PresenceHandler.get_user_color(\"bob\")\n\n    assert first == second\n    assert is_binary(third)\n    refute third == \"\"\n    refute Map.equal?(:persistent_term.get(:user_colors), %{})\n  end\n\n  test \"track_presence/2 tracks connected users and anonymous visitors\" do\n    test_pid = self()\n\n    with_mock SoundboardWeb.Presence,\n      track: fn pid, topic, socket_id, payload ->\n        send(test_pid, {:tracked, pid, topic, socket_id, payload})\n        :ok\n      end,\n      list: fn _topic -> %{} end do\n      socket = %Phoenix.LiveView.Socket{id: \"abcdef123\", transport_pid: self()}\n      user = %{username: \"alice\", avatar: \"avatar.png\"}\n\n      assert :ok = PresenceHandler.track_presence(socket, user)\n\n      assert_receive {:tracked, _pid, \"soundboard:presence\", \"abcdef123\",\n                      %{user: %{username: \"alice\", avatar: \"avatar.png\", color: color}}}\n\n      assert is_binary(color)\n\n      anonymous_socket = %Phoenix.LiveView.Socket{id: \"anon999\", transport_pid: self()}\n\n      assert :ok = PresenceHandler.track_presence(anonymous_socket, nil)\n\n      assert_receive {:tracked, _pid, \"soundboard:presence\", \"anon999\",\n                      %{user: %{username: \"Anonymous anon99\", avatar: nil}}}\n    end\n  end\n\n  test \"track_presence/2 is a no-op for disconnected sockets\" do\n    with_mock SoundboardWeb.Presence, track: fn _, _, _, _ -> flunk(\"should not track\") end do\n      socket = %Phoenix.LiveView.Socket{id: \"offline\", transport_pid: nil}\n      assert PresenceHandler.track_presence(socket, nil) == nil\n    end\n  end\n\n  test \"get_presence_count/0 and handle_presence_diff/2 count only active presences\" do\n    now = System.system_time(:second)\n\n    presences = %{\n      \"fresh\" => %{metas: [%{online_at: now - 10}]},\n      \"stale\" => %{metas: [%{online_at: now - 120}]},\n      \"empty\" => %{metas: []}\n    }\n\n    with_mock SoundboardWeb.Presence, list: fn _topic -> presences end do\n      assert PresenceHandler.get_presence_count() == 1\n    end\n\n    diff = %{\n      joins: %{\n        \"joiner\" => %{metas: [%{online_at: now - 5}]}\n      },\n      leaves: %{\n        \"old\" => %{metas: [%{online_at: now - 300}]},\n        \"recent\" => %{metas: [%{online_at: now - 5}]}\n      }\n    }\n\n    assert PresenceHandler.handle_presence_diff(diff, 2) == 2\n    assert PresenceHandler.handle_presence_diff(diff, 0) == 0\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/sound_helpers_test.exs",
    "content": "defmodule SoundboardWeb.SoundHelpersTest do\n  use ExUnit.Case, async: true\n  alias SoundboardWeb.SoundHelpers\n\n  describe \"display_name/1\" do\n    test \"strips extension and directories\" do\n      assert SoundHelpers.display_name(\"priv/static/uploads/beep.mp3\") == \"beep\"\n    end\n\n    test \"handles values without extension\" do\n      assert SoundHelpers.display_name(\"wow\") == \"wow\"\n    end\n\n    test \"handles nil\" do\n      assert SoundHelpers.display_name(nil) == \"\"\n    end\n\n    test \"stringifies non-binary values\" do\n      assert SoundHelpers.display_name(123) == \"123\"\n    end\n  end\n\n  describe \"slugify/1\" do\n    test \"converts filename to lower-case slug\" do\n      assert SoundHelpers.slugify(\"Wow Sound.MP3\") == \"wow-sound\"\n    end\n\n    test \"falls back to default\" do\n      assert SoundHelpers.slugify(nil) == \"sound\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/soundboard_web/soundboard/sound_filter_test.exs",
    "content": "defmodule SoundboardWeb.Soundboard.SoundFilterTest do\n  use ExUnit.Case, async: true\n\n  alias SoundboardWeb.Soundboard.SoundFilter\n\n  describe \"filter_sounds/3\" do\n    test \"keeps sounds matching every selected tag\" do\n      alpha = %{id: 1, name: \"alpha\"}\n      beta = %{id: 2, name: \"beta\"}\n\n      sounds = [\n        %{filename: \"alpha-beta.mp3\", tags: [alpha, beta]},\n        %{filename: \"alpha-only.mp3\", tags: [alpha]},\n        %{filename: \"beta-only.mp3\", tags: [beta]}\n      ]\n\n      assert [matched] = SoundFilter.filter_sounds(sounds, \"\", [alpha, beta])\n      assert matched.filename == \"alpha-beta.mp3\"\n    end\n\n    test \"matches against filenames and tag names\" do\n      alpha = %{id: 1, name: \"alpha\"}\n      reaction = %{id: 2, name: \"reaction\"}\n\n      sounds = [\n        %{filename: \"victory.mp3\", tags: [alpha]},\n        %{filename: \"sad-trombone.mp3\", tags: [reaction]}\n      ]\n\n      assert [%{filename: \"victory.mp3\"}] = SoundFilter.filter_sounds(sounds, \"victory\", [])\n\n      assert [%{filename: \"sad-trombone.mp3\"}] =\n               SoundFilter.filter_sounds(sounds, \"reaction\", [])\n    end\n  end\nend\n"
  },
  {
    "path": "test/support/conn_case.ex",
    "content": "defmodule SoundboardWeb.ConnCase do\n  @moduledoc false\n\n  use ExUnit.CaseTemplate\n  require Phoenix.LiveViewTest\n  @endpoint SoundboardWeb.Endpoint\n\n  using do\n    quote do\n      import Plug.Conn\n      import Phoenix.ConnTest\n      import Phoenix.LiveViewTest\n      import SoundboardWeb.ConnCase\n      import Soundboard.TestHelpers\n\n      alias SoundboardWeb.Router.Helpers, as: Routes\n\n      # The default endpoint for testing\n      @endpoint SoundboardWeb.Endpoint\n\n      use SoundboardWeb, :verified_routes\n    end\n  end\n\n  setup tags do\n    Soundboard.DataCase.setup_sandbox(tags)\n    {:ok, conn: Phoenix.ConnTest.build_conn()}\n  end\n\n  def file_upload(lv, field, entries) do\n    {entries, _refs} =\n      Enum.reduce(entries, {[], []}, fn entry, {entries, refs} ->\n        ref = entry[:ref] || \"phx-#{System.unique_integer()}\"\n\n        entry =\n          Map.merge(\n            %{\n              name: \"test.mp3\",\n              content: \"test\",\n              size: 9999,\n              type: \"audio/mpeg\",\n              ref: ref,\n              done?: true\n            },\n            entry\n          )\n\n        {[entry | entries], [ref | refs]}\n      end)\n\n    entries = Enum.reverse(entries)\n\n    for entry <- entries do\n      Phoenix.LiveViewTest.file_input(lv, field, entry, entry.ref)\n    end\n  end\nend\n"
  },
  {
    "path": "test/support/data_case.ex",
    "content": "defmodule Soundboard.DataCase do\n  @moduledoc false\n\n  use ExUnit.CaseTemplate\n  alias Ecto.Adapters.SQL.Sandbox\n\n  using do\n    quote do\n      alias Soundboard.Repo\n\n      import Ecto\n      import Ecto.Changeset\n      import Ecto.Query\n      import Soundboard.DataCase\n    end\n  end\n\n  setup tags do\n    pid = Sandbox.start_owner!(Soundboard.Repo, shared: not tags[:async])\n    on_exit(fn -> Sandbox.stop_owner(pid) end)\n    :ok\n  end\n\n  @doc \"\"\"\n  Sets up the sandbox based on the test tags.\n  \"\"\"\n  def setup_sandbox(tags) do\n    pid = Sandbox.start_owner!(Soundboard.Repo, shared: not tags[:async])\n    on_exit(fn -> Sandbox.stop_owner(pid) end)\n  end\n\n  @doc \"\"\"\n  A helper that transforms changeset errors into a map of messages.\n\n      assert {:error, changeset} = Accounts.create_user(%{password: \"short\"})\n      assert \"password is too short\" in errors_on(changeset).password\n      assert %{password: [\"password is too short\"]} = errors_on(changeset)\n\n  \"\"\"\n  def errors_on(changeset) do\n    Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->\n      Regex.replace(~r\"%{(\\w+)}\", message, fn _, key ->\n        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()\n      end)\n    end)\n  end\nend\n"
  },
  {
    "path": "test/support/test_helpers.ex",
    "content": "defmodule Soundboard.TestHelpers do\n  @moduledoc \"\"\"\n  Helper functions for testing.\n  \"\"\"\n  alias Soundboard.{Accounts, Repo, Sound, Tag}\n\n  def create_test_file(filename) do\n    test_dir = \"test/support/fixtures\"\n    File.mkdir_p!(test_dir)\n    path = Path.join(test_dir, filename)\n    File.write!(path, \"test audio content\")\n    path\n  end\n\n  def cleanup_test_files do\n    File.rm_rf!(\"test/support/fixtures\")\n  end\n\n  def setup_test_socket(assigns \\\\ %{}) do\n    %Phoenix.LiveView.Socket{\n      assigns:\n        Map.merge(\n          %{\n            current_user: nil,\n            current_sound: nil,\n            uploads: %{},\n            flash: %{}\n          },\n          assigns\n        )\n    }\n  end\n\n  def setup_upload_socket(user) do\n    setup_test_socket(%{\n      current_user: user,\n      uploads: %{\n        audio: %Phoenix.LiveView.UploadConfig{\n          entries: [],\n          ref: \"test-ref\",\n          max_entries: 1,\n          max_file_size: 10_000_000,\n          chunk_size: 64_000,\n          chunk_timeout: 10_000,\n          accept: ~w(.mp3 .wav .ogg .m4a)\n        }\n      }\n    })\n  end\n\n  def setup_test_audio_file do\n    test_dir = \"test/support/fixtures\"\n    File.mkdir_p!(test_dir)\n    file_path = Path.join(test_dir, \"test_sound.mp3\")\n    File.write!(file_path, \"test audio content\")\n    file_path\n  end\n\n  def create_user(attrs \\\\ %{}) do\n    user_attrs =\n      Enum.into(attrs, %{\n        username: \"testuser\",\n        discord_id: \"123456789\",\n        avatar: \"test_avatar.jpg\"\n      })\n\n    %Soundboard.Accounts.User{}\n    |> Accounts.User.changeset(user_attrs)\n    |> Soundboard.Repo.insert()\n  end\n\n  def create_sound(user, attrs \\\\ %{}) do\n    attrs =\n      Map.merge(\n        %{\n          name: \"test_sound#{System.unique_integer()}\",\n          file_path: setup_test_audio_file(),\n          user_id: user.id\n        },\n        attrs\n      )\n\n    %Sound{}\n    |> Sound.changeset(attrs)\n    |> Repo.insert()\n  end\n\n  def create_tag(name) when is_binary(name) do\n    %Tag{}\n    |> Tag.changeset(%{name: name})\n    |> Repo.insert()\n  end\nend\n"
  },
  {
    "path": "test/test_helper.exs",
    "content": "ExUnit.start()\n\nApplication.ensure_all_started(:soundboard)\n\nEcto.Adapters.SQL.Sandbox.mode(Soundboard.Repo, :manual)\n"
  }
]