//
plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
// Embeds Heroicons (https://heroicons.com) into your app.css bundle
// See your `CoreComponents.icon/1` for more information.
//
plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})
]
}
================================================
FILE: assets/vendor/topbar.js
================================================
/**
* @license MIT
* topbar 2.0.0, 2023-02-04
* https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));
================================================
FILE: config/config.exs
================================================
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
# config :soundboard,
# ecto_repos: [Soundboard.Repo],
# generators: [timestamp_type: :utc_datetime],
# token: System.get_env("DISCORD_TOKEN")
# EDA config shared across environments.
# Prefix commands like `!join` require :message_content intent.
config :eda,
intents: [:guilds, :guild_messages, :guild_voice_states, :message_content],
consumer: Soundboard.Discord.Consumer,
gateway_encoding: :etf,
dave: true
# Configures the endpoint
config :soundboard, SoundboardWeb.Endpoint,
url: [host: "localhost", port: 4000],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: SoundboardWeb.ErrorHTML, json: SoundboardWeb.ErrorJSON],
layout: false
],
pubsub_server: Soundboard.PubSub,
live_view: [signing_salt: "9gxiIiqP"]
# Configure esbuild (the version is required)
config :esbuild,
version: "0.17.11",
soundboard: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "3.4.3",
soundboard: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
--output=../priv/static/assets/app.css
),
cd: Path.expand("../assets", __DIR__)
]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Add MIME types for audio files
config :mime, :types, %{
"audio/mpeg" => ["mp3"],
"audio/ogg" => ["ogg"],
"audio/wav" => ["wav"],
"audio/x-m4a" => ["m4a"]
}
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
# Add this near the top of the file
config :soundboard,
ecto_repos: [Soundboard.Repo]
# Add this somewhere in the file
config :soundboard, Soundboard.Repo,
database: "priv/static/uploads/database.db",
pool_size: 5
config :phoenix_live_view,
flash_timeout: 3000
config :soundboard, SoundboardWeb.Presence, pubsub_server: Soundboard.PubSub
# Optional voice startup probe (disabled by default)
config :soundboard,
voice_rtp_probe: false,
voice_rtp_probe_timeout_ms: 6_000
# Add this with your other configs
config :ueberauth, Ueberauth,
providers: [
discord: {Ueberauth.Strategy.Discord, [default_scope: "identify"]}
]
================================================
FILE: config/dev.exs
================================================
import Config
config :soundboard, Soundboard.Repo,
database: "database.db",
adapter: Ecto.Adapters.SQLite3
generate_secret_key_base = fn ->
Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
end
derive_secret_key_base = fn value ->
:crypto.hash(:sha512, value)
|> Base.encode64(padding: false)
end
secret_key_base =
case System.get_env("SECRET_KEY_BASE") do
value when is_binary(value) and byte_size(value) >= 64 ->
value
value when is_binary(value) ->
derive_secret_key_base.(value)
_ ->
generate_secret_key_base.()
end
config :soundboard, SoundboardWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
url: [host: "localhost", port: 4000, scheme: "http"],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: secret_key_base,
watchers: [
esbuild: {Esbuild, :install_and_run, [:soundboard, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:soundboard, ~w(--watch)]}
]
config :soundboard, SoundboardWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/soundboard_web/(controllers|live|components)/.*(ex|heex)$"
]
]
config :soundboard, dev_routes: true
config :logger, :console, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
debug_heex_annotations: true,
enable_expensive_runtime_checks: true
config :swoosh, :api_client, false
config :soundboard, env: :dev
================================================
FILE: config/prod.exs
================================================
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :soundboard, SoundboardWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
config :soundboard, Soundboard.Repo,
database: "/app/priv/static/uploads/soundboard_prod.db",
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# Ensure the uploads directory exists
config :soundboard,
upload_directory: "/app/priv/static/uploads"
config :soundboard,
env: :prod
# Configure logging for production - enable debug level for voice troubleshooting
config :logger, level: :debug
================================================
FILE: config/runtime.exs
================================================
import Config
import Dotenvy
env_dir_prefix = System.get_env("RELEASE_ROOT") || Path.expand(".")
source!([
Path.absname(".env", env_dir_prefix),
Path.absname(".#{config_env()}.env", env_dir_prefix),
Path.absname(".#{config_env()}.overrides.env", env_dir_prefix),
System.get_env()
])
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/soundboard start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if env!("PHX_SERVER", :boolean, false) do
config :soundboard, SoundboardWeb.Endpoint, server: true
end
if config_env() == :dev do
host = env!("PHX_HOST", :string!, "localhost:4000")
scheme = env!("SCHEME", :string!, "http")
port = env!("PORT", :integer, 4000)
callback_url = "#{scheme}://#{host}/auth/discord/callback"
discord_token = env!("DISCORD_TOKEN", :string!, nil)
client_id = env!("DISCORD_CLIENT_ID", :string!, nil)
client_secret = env!("DISCORD_CLIENT_SECRET", :string!, nil)
eda_dave = env!("EDA_DAVE", :boolean, true)
voice_rtp_probe = env!("VOICE_RTP_PROBE", :boolean, false)
voice_rtp_probe_timeout_ms = env!("VOICE_RTP_PROBE_TIMEOUT_MS", :integer, 6_000)
secret_key_base =
case env!("SECRET_KEY_BASE", :string!, nil) do
value when is_binary(value) and byte_size(value) >= 64 ->
value
value when is_binary(value) ->
:crypto.hash(:sha512, value)
|> Base.encode64(padding: false)
_ ->
nil
end
bind_ip =
case env!("BIND_IP", :string!, "127.0.0.1")
|> String.to_charlist()
|> :inet.parse_address() do
{:ok, ip_tuple} -> ip_tuple
_ -> {127, 0, 0, 1}
end
endpoint_overrides = [
url: [host: host, port: port, scheme: scheme],
http: [ip: bind_ip, port: port]
]
endpoint_overrides =
if is_binary(secret_key_base) do
Keyword.put(endpoint_overrides, :secret_key_base, secret_key_base)
else
endpoint_overrides
end
config :soundboard, SoundboardWeb.Endpoint, endpoint_overrides
config :ueberauth, Ueberauth.Strategy.Discord.OAuth,
client_id: client_id,
client_secret: client_secret,
redirect_uri: callback_url
ffmpeg_available = not is_nil(System.find_executable("ffmpeg"))
unless ffmpeg_available do
IO.warn(
"ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed."
)
end
required_guild_id = env!("DISCORD_REQUIRED_GUILD_ID", :string, nil)
required_role_ids =
"DISCORD_REQUIRED_ROLE_IDS"
|> env!(:string, "")
|> String.split(",", trim: true)
role_recheck_interval_seconds = env!("DISCORD_ROLE_RECHECK_INTERVAL_SECONDS", :integer, 900)
config :soundboard,
discord_token: discord_token,
voice_rtp_probe: voice_rtp_probe,
voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,
ffmpeg_available: ffmpeg_available,
required_guild_id: required_guild_id,
required_role_ids: required_role_ids,
role_recheck_interval_seconds: role_recheck_interval_seconds
config :eda,
token: discord_token,
dave: eda_dave
end
# Allow build tooling to opt-out to avoid requiring secrets during image builds.
if config_env() == :prod and is_nil(env!("SKIP_RUNTIME_CONFIG", :string, nil)) do
port = env!("PORT", :integer, 4000)
# Replace the database_url section with SQLite configuration
database_path = Path.join(:code.priv_dir(:soundboard), "static/uploads/soundboard_prod.db")
config :soundboard, Soundboard.Repo,
database: database_path,
adapter: Ecto.Adapters.SQLite3,
pool_size: env!("POOL_SIZE", :integer, 10)
# The secret key base is used to sign/encrypt cookies and other secrets.
secret_key_base =
case env!("SECRET_KEY_BASE", :string!, nil) do
value when is_binary(value) ->
value
_ ->
case env!("SECRET_KEY_BASE_FILE", :string!, nil) do
file when is_binary(file) ->
case File.read(file) do
{:ok, key} ->
String.trim(key)
{:error, reason} ->
raise """
could not read SECRET_KEY_BASE_FILE (#{file}): #{inspect(reason)}
"""
end
_ ->
raise """
environment variable SECRET_KEY_BASE is missing.
Provide it via your environment (recommended) or set SECRET_KEY_BASE_FILE to a file path containing the key.
Generate one with: mix phx.gen.secret OR openssl rand -base64 48
"""
end
end
host = env!("PHX_HOST", :string!)
scheme = env!("SCHEME", :string!, "https")
callback_url = "#{scheme}://#{host}/auth/discord/callback"
# Configure endpoint first
config :soundboard, SoundboardWeb.Endpoint,
# In prod, PHX_HOST represents the externally visible host. Do not append
# the app's internal listen port unless the host itself already includes one.
url: [
scheme: scheme,
host: host,
port: nil
],
http: [
ip: {0, 0, 0, 0},
port: port
],
static_url: [
host: host,
port: nil
],
check_origin: false,
force_ssl: scheme == "https",
secret_key_base: secret_key_base,
session: [
store: :cookie,
key: "_soundboard_key",
signing_salt: secret_key_base
]
# Configure Ueberauth
config :ueberauth, Ueberauth,
providers: [
discord: {Ueberauth.Strategy.Discord, [default_scope: "identify"]}
]
# Configure Discord OAuth
config :ueberauth, Ueberauth.Strategy.Discord.OAuth,
client_id: env!("DISCORD_CLIENT_ID", :string!),
client_secret: env!("DISCORD_CLIENT_SECRET", :string!),
redirect_uri: callback_url
# Configure Discord bot token
discord_token = env!("DISCORD_TOKEN", :string!)
# Store token for application use (bot will fetch it from here)
voice_rtp_probe = env!("VOICE_RTP_PROBE", :boolean, false)
voice_rtp_probe_timeout_ms = env!("VOICE_RTP_PROBE_TIMEOUT_MS", :integer, 6_000)
eda_dave = env!("EDA_DAVE", :boolean, true)
ffmpeg_available = not is_nil(System.find_executable("ffmpeg"))
unless ffmpeg_available do
IO.warn(
"ffmpeg not found in PATH. Voice playback features will be unavailable until ffmpeg is installed."
)
end
required_guild_id = env!("DISCORD_REQUIRED_GUILD_ID", :string, nil)
required_role_ids =
"DISCORD_REQUIRED_ROLE_IDS"
|> env!(:string, "")
|> String.split(",", trim: true)
role_recheck_interval_seconds = env!("DISCORD_ROLE_RECHECK_INTERVAL_SECONDS", :integer, 900)
config :soundboard,
discord_token: discord_token,
voice_rtp_probe: voice_rtp_probe,
voice_rtp_probe_timeout_ms: voice_rtp_probe_timeout_ms,
ffmpeg_available: ffmpeg_available,
required_guild_id: required_guild_id,
required_role_ids: required_role_ids,
role_recheck_interval_seconds: role_recheck_interval_seconds
config :eda,
token: discord_token,
dave: eda_dave
# Configure logger for production
config :logger,
# Set minimum log level to debug to see IO.puts
level: :debug,
compile_time_purge_matching: [
# Don't purge debug logs
[level_lower_than: :debug]
]
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id, :error],
# Enable colors for better visibility
colors: [enabled: true]
# Keep stacktraces in production for better error reporting
config :phoenix,
stacktrace_depth: 20,
plug_init_mode: :runtime
config :soundboard, :env, :prod
end
================================================
FILE: config/test.exs
================================================
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :soundboard, Soundboard.Repo,
adapter: Ecto.Adapters.SQLite3,
database: Path.expand("../soundboard_test.db", Path.dirname(__ENV__.file)),
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 1,
busy_timeout: 5000,
journal_mode: :wal
# We don't run a server during test. If one is required,
# you can enable the server option below.
generate_secret_key_base = fn ->
Base.encode64(:crypto.strong_rand_bytes(64), padding: false)
end
derive_secret_key_base = fn value ->
:crypto.hash(:sha512, value)
|> Base.encode64(padding: false)
end
secret_key_base =
case System.get_env("SECRET_KEY_BASE_TEST") do
value when is_binary(value) and byte_size(value) >= 64 ->
value
value when is_binary(value) ->
derive_secret_key_base.(value)
_ ->
generate_secret_key_base.()
end
config :soundboard, SoundboardWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: secret_key_base,
server: false
# Print only warnings and errors during test
config :logger, level: :warning
# Configure the console backend
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id, :file, :line]
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
config :soundboard, :sql_sandbox, true
config :soundboard, env: :test
config :soundboard, Soundboard.AudioPlayer, voice_maintenance_enabled: false
config :soundboard, Soundboard.PubSub,
adapter: Phoenix.PubSub.PG2,
name: Soundboard.PubSub
config :eda,
token: nil,
consumer: nil,
dave: false
================================================
FILE: coveralls.json
================================================
{
"skip_files": [
"lib/soundboard_web/presence.ex",
"lib/soundboard_web/telemetry.ex",
"lib/soundboard_web/gettext.ex",
"lib/soundboard_web/endpoint.ex",
"lib/soundboard_web/router.ex",
"lib/soundboard_web/components/",
"lib/soundboard_web/live/",
"lib/soundboard_web/audio_player.ex",
"lib/soundboard_web/discord_handler.ex",
"lib/soundboard_web/controllers/upload_controller.ex",
"lib/soundboard_web/controllers/error_html.ex",
"lib/soundboard_web/controllers/error_json.ex",
"lib/soundboard_web/eda_consumer.ex",
"lib/soundboard/discord/guild_cache.ex",
"lib/soundboard/discord/message.ex",
"lib/soundboard/discord/self.ex",
"lib/soundboard/discord/voice.ex",
"lib/soundboard/audio_player.ex",
"lib/soundboard/audio_player/playback_engine.ex",
"lib/soundboard/discord/handler/voice_runtime.ex",
"lib/soundboard/release.ex",
"lib/soundboard/repo.ex",
"lib/soundboard/application.ex",
"lib/soundboard_web.ex",
"test"
]
}
================================================
FILE: docker-compose.yml
================================================
services:
soundbored:
image: christom/soundbored:latest
ports:
- "127.0.0.1:4000:4000"
env_file: .env
volumes:
- soundbored_data:/app/priv/static/uploads
# the below can be added for improved security
- type: tmpfs
target: /tmp/mix_pubsub
tmpfs:
mode: 0777
user: 9999:9999 # make sure this user has permissions on the soundbored_data volume
read_only: true
environment:
TMPDIR: /tmp/mix_pubsub
SECRET_KEY_BASE_FILE: /run/secrets/soundbored_secret_key_base
secrets:
- soundbored_secret_key_base
volumes:
soundbored_data:
secrets:
soundbored_secret_key_base:
# this file should contain securely-generated random data (see README.md)
# and only be readable by root or the user running the docker daemon
file: ./secret_key_base.txt
================================================
FILE: docs/plans/discord-role-gated-access.md
================================================
# Discord Role-Gated Access Implementation Plan
**Status:** Complete
**Spec:** `docs/specs/discord-role-gated-access.md`
**Goal:** Restrict web access to Discord users who hold a configured role in a configured guild, with verification at login and periodic re-checks.
---
## Tasks
### Task 1: Config plumbing ✓
Added `DISCORD_REQUIRED_GUILD_ID`, `DISCORD_REQUIRED_ROLE_IDS`, and `DISCORD_ROLE_RECHECK_INTERVAL_SECONDS` to `config/runtime.exs` (both `:dev` and `:prod` blocks). Updated `.env.example`.
### Task 2: Soundboard.Discord.RoleChecker ✓
New module at `lib/soundboard/discord/role_checker.ex`. Exposes `feature_enabled?/0` and `authorized?/1`. Wraps `EDA.API.Member.get/2`, fails closed on any error.
Tests: `test/soundboard/discord/role_checker_test.exs` (8 tests).
### Task 3: Login-time role check in AuthController ✓
`AuthController.callback/2` calls `RoleChecker.authorized?/1` before `find_or_create_user`. Stores `:roles_verified_at` in session on success. No DB write on failure.
Tests: `test/soundboard_web/controllers/auth_controller_test.exs` (updated).
### Task 4: SoundboardWeb.Plugs.RoleCheck ✓
New plug at `lib/soundboard_web/plugs/role_check.ex`. Re-verifies role membership when session timestamp is missing or stale. Clears session and halts on unauthorized.
Tests: `test/soundboard_web/plugs/role_check_test.exs` (6 tests).
### Task 5: Wire plug into router ✓
Added `:require_role_check` pipeline to `lib/soundboard_web/router.ex`, inserted into both protected scopes after `:ensure_authenticated_user`.
================================================
FILE: docs/plans/discord-role-gated-access.md.tasks.json
================================================
{
"planPath": "docs/plans/discord-role-gated-access.md",
"tasks": [
{
"id": 2,
"subject": "Task 1: Add config plumbing for required guild/role env vars",
"status": "completed",
"description": "**Goal:** New env vars are read by runtime.exs and stored under :soundboard app config keys.\n\n**Files:** config/runtime.exs, .env.example\n\n**Verify:** mix compile --warnings-as-errors\n\n```json:metadata\n{\"files\": [\"config/runtime.exs\", \".env.example\"], \"verifyCommand\": \"mix compile --warnings-as-errors\", \"acceptanceCriteria\": [\"required_guild_id defaults nil\", \"required_role_ids defaults []\", \"role_recheck_interval_seconds defaults 900\"]}\n```"
},
{
"id": 3,
"subject": "Task 2: Implement Soundboard.Discord.RoleChecker with TDD",
"status": "completed",
"blockedBy": [2],
"description": "**Goal:** New module wraps EDA.API.Member.get/2 with feature_enabled?/0 and authorized?/1.\n\n**Files:** lib/soundboard/discord/role_checker.ex, test/soundboard/discord/role_checker_test.exs\n\n**Verify:** mix test test/soundboard/discord/role_checker_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard/discord/role_checker.ex\", \"test/soundboard/discord/role_checker_test.exs\"], \"verifyCommand\": \"mix test test/soundboard/discord/role_checker_test.exs\", \"acceptanceCriteria\": [\"feature_enabled? correct\", \"authorized? short-circuits when disabled\", \"matches any required role\", \"false on error\"]}\n```"
},
{
"id": 4,
"subject": "Task 3: Add login-time role check to AuthController",
"status": "completed",
"blockedBy": [3],
"description": "**Goal:** OAuth callback verifies role membership before creating local user. Stores :user_id and :roles_verified_at on success.\n\n**Files:** lib/soundboard_web/controllers/auth_controller.ex, test/soundboard_web/controllers/auth_controller_test.exs\n\n**Verify:** mix test test/soundboard_web/controllers/auth_controller_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/controllers/auth_controller.ex\", \"test/soundboard_web/controllers/auth_controller_test.exs\"], \"verifyCommand\": \"mix test test/soundboard_web/controllers/auth_controller_test.exs\", \"acceptanceCriteria\": [\"authorized branch sets both session keys\", \"unauthorized creates no user record\", \"existing tests pass\"]}\n```"
},
{
"id": 5,
"subject": "Task 4: Implement SoundboardWeb.Plugs.RoleCheck with TDD",
"status": "completed",
"blockedBy": [3],
"description": "**Goal:** Plug runs after ensure_authenticated_user. Re-verifies via RoleChecker when timestamp is stale/missing. Halts and clears session on failure.\n\n**Files:** lib/soundboard_web/plugs/role_check.ex, test/soundboard_web/plugs/role_check_test.exs\n\n**Verify:** mix test test/soundboard_web/plugs/role_check_test.exs\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/plugs/role_check.ex\", \"test/soundboard_web/plugs/role_check_test.exs\"], \"verifyCommand\": \"mix test test/soundboard_web/plugs/role_check_test.exs\", \"acceptanceCriteria\": [\"pass through cases skip API\", \"stale/missing triggers re-check\", \"unauthorized halts and clears\"]}\n```"
},
{
"id": 6,
"subject": "Task 5: Wire RoleCheck plug into protected pipeline",
"status": "completed",
"blockedBy": [5],
"description": "**Goal:** Both protected scopes invoke the plug. Full precommit passes.\n\n**Files:** lib/soundboard_web/router.ex\n\n**Verify:** mix precommit\n\n```json:metadata\n{\"files\": [\"lib/soundboard_web/router.ex\"], \"verifyCommand\": \"mix precommit\", \"acceptanceCriteria\": [\"pipeline defined\", \"both scopes wired\", \"full precommit passes\"]}\n```"
}
],
"lastUpdated": "2026-04-27T17:11:00Z"
}
================================================
FILE: docs/specs/discord-role-gated-access.md
================================================
# Spec: Discord Role-Gated Access
**Status:** Implemented
**Date:** 2026-04-27
---
## Summary
Restrict web access to Discord users who are members of a configured guild **and** hold at least one configured role. Verification happens at OAuth login and is re-checked periodically per session. When unconfigured, behavior is unchanged — anyone who completes Discord OAuth gets in.
---
## Configuration
Three env vars parsed in `config/runtime.exs`:
| Env Var | Type | Default | Description |
|---|---|---|---|
| `DISCORD_REQUIRED_GUILD_ID` | string | `nil` | Guild snowflake ID |
| `DISCORD_REQUIRED_ROLE_IDS` | comma-separated strings | `""` | Role snowflake IDs; user must hold at least one |
| `DISCORD_ROLE_RECHECK_INTERVAL_SECONDS` | integer | `900` | Seconds between role re-checks per session |
Feature is **enabled** only when both `DISCORD_REQUIRED_GUILD_ID` is set and `DISCORD_REQUIRED_ROLE_IDS` is non-empty. Either unset → feature disabled, no role checks performed.
---
## Behavior
| Scenario | Result |
|---|---|
| Feature not configured | Open access — current behavior preserved |
| User authenticates, has a required role | Logged in normally |
| User authenticates, lacks required role | No user record created; redirected to `/` with `"Error signing in"` flash |
| Logged-in user loses required role | At next re-check, session cleared, redirected to `/` |
| Discord API call fails | Fails closed — user denied |
The flash message is intentionally generic to avoid leaking why access was denied.
---
## Architecture
**`Soundboard.Discord.RoleChecker`** (`lib/soundboard/discord/role_checker.ex`)
Wraps `EDA.API.Member.get/2`. `feature_enabled?/0` checks config; `authorized?/1` short-circuits to `true` when disabled, otherwise checks guild role membership. Fails closed on any error or unexpected API response.
**`SoundboardWeb.AuthController`**
The OAuth callback calls `RoleChecker.authorized?/1` before `find_or_create_user`. On success, stores `:user_id` and `:roles_verified_at` in session. On failure, no DB write, generic flash.
**`SoundboardWeb.Plugs.RoleCheck`** (`lib/soundboard_web/plugs/role_check.ex`)
Runs after `ensure_authenticated_user`. Pass-through when: no `current_user`, feature disabled, or `:roles_verified_at` is fresh. Otherwise re-checks via `RoleChecker.authorized?/1` — updates timestamp on success, clears session and halts on failure.
**Router:** New `:require_role_check` pipeline wired into both protected scopes (`/` and `/uploads`) after `:ensure_authenticated_user`.
---
## Out of Scope
- Per-LiveView/per-action authorization
- Multi-guild support
- Re-checking on WebSocket lifecycle (LiveView mount)
- Dedicated denial page
================================================
FILE: docs/specs/voice-auto-join-idle-leave.md
================================================
# Spec: Voice Channel Auto-Join on Playback & Idle Auto-Leave
**Status:** Implemented
**Date:** 2026-04-29
**Author:** Justin Hart
---
## Summary
Three related quality-of-life improvements to bot voice channel management, unified under a single `AUTO_JOIN` mode enum:
1. **Auto-join on play** (`play` mode): when a user triggers sound playback from the web UI or API and the bot is not in any voice channel, the bot automatically joins the user's current Discord voice channel before playing.
2. **Idle auto-leave**: after a configurable period of inactivity (default: 600 seconds), the bot leaves the voice channel. Behavior varies by mode.
3. **`AUTO_JOIN` mode enum**: replaces the old boolean flag with a three-value enum (`play`, `presence`, `false`) that unifies join and leave behavior into one setting.
---
## Motivation
Previously, users had to manually type `!join` in Discord before playing sounds from the web UI. This broke the flow: open the soundboard tab, click a sound, nothing happens, switch to Discord, type `!join`, switch back, click again. The bot also had no way to clean up a lingering voice session after everyone drifted away from a channel.
The original implementation added auto-join on play and a global idle timeout. A follow-up redesign replaced the boolean `AUTO_JOIN` flag with a proper mode enum, aligning join behavior, leave behavior, and idle timeout semantics into a coherent model.
---
## User-Facing Behavior
### `AUTO_JOIN` Modes
| Mode | How bot joins | How bot leaves |
|---|---|---|
| `play` (default) | Joins when a sound is played from the web UI or API | Leaves when last user departs **or** after `VOICE_IDLE_TIMEOUT_SECONDS` of no audio (whichever first). If timeout ≤ 0, only leaves when last user departs. |
| `presence` | Follows users into channels on voice-state updates (existing behavior) | Leaves immediately when last user departs. Idle timeout is **ignored**. |
| `false` | Manual `!join` only | If timeout > 0: leaves after `VOICE_IDLE_TIMEOUT_SECONDS` of being alone (timer starts when last user departs, cancels if a user rejoins). If timeout ≤ 0: never auto-leaves. |
The old `AUTO_JOIN=true` maps to `presence`; `AUTO_JOIN=false` (explicit) maps to `false`; unset now defaults to `play`.
### Auto-Join on Play (in `play` mode)
| Situation | Behavior |
|---|---|
| Bot has no voice channel, user clicks a sound | Bot joins the user's current voice channel and plays the sound |
| User is not in any voice channel | Error: "Bot is not connected to a voice channel. Use !join in Discord first." |
| Actor is `System` (join/leave sounds) | Same error (no Discord identity to look up) |
| Bot already in a channel | Plays normally, unchanged |
| Mode is `presence` or `false` | Error (no auto-join, must use `!join`) |
### Idle Timeout Semantics by Mode
| Event | `play` mode | `presence` mode | `false` mode |
|---|---|---|---|
| Bot joins a channel | Timer starts (if timeout > 0) | No timer | No timer |
| `play_sound` cast | Timer resets | No effect | No effect |
| Last user leaves | Leave immediately | Leave immediately | Start idle timer (if timeout > 0) |
| User rejoins (bot alone) | N/A | N/A | Cancel idle timer |
| Idle timer fires | Leave immediately | Never fires | Leave immediately |
| Bot leaves (any reason) | Timer cancelled | N/A | Timer cancelled |
---
## Architecture & Design
### `AUTO_JOIN` Enum
`AutoJoinPolicy.mode/0` now returns `:presence | :play | false` (the boolean `false`, not an atom, per Elixir convention). Parsing rules:
| `AUTO_JOIN` value | Mode |
|---|---|
| not set | `:play` |
| `play` | `:play` |
| `presence`, `true`, `1`, `yes` | `:presence` |
| `false`, `0`, `no`, any other | `false` |
### Auto-Join Flow (play mode only)
```
Web UI / API → AudioPlayer.play_sound(name, %User{discord_id: ...})
│
▼ (voice_channel is nil AND mode == :play)
AudioPlayer.handle_cast({:play_sound, ...}, %{voice_channel: nil})
│
├─ extract discord_id from actor
│
▼
VoicePresence.find_user_voice_channel(discord_id)
│ searches all cached guilds via GuildCache
│
├─ {:ok, {guild_id, channel_id}} ──► Voice.join_channel(guild_id, channel_id)
│ update state.voice_channel
│ schedule idle timer (if timeout > 0)
│ proceed with playback
│
└─ :not_found ──► Notifier.error("Bot is not connected... Use !join in Discord first.")
```
The join happens directly inside the `handle_cast` body — state is updated synchronously, and playback proceeds in the same message handler. This avoids any circular-cast problem: `VoiceCommands.join_voice_channel` normally calls `AudioPlayer.set_voice_channel` as a success callback, which would deadlock if called from inside `AudioPlayer`. By calling `Voice.join_channel/2` directly and updating state ourselves, we avoid that dependency entirely.
### Last-User-Left Routing
`VoiceRuntime.handle_disconnect` now runs for **all** modes (previously short-circuited for `:disabled`). When the bot is confirmed alone, `bot_alone_action/1` dispatches by mode:
```
bot_alone_action(guild_id)
:presence ──► VoiceCommands.leave_voice_channel(guild_id) (existing path)
:play ──► AudioPlayer.last_user_left(guild_id) (leave immediately)
false ──► AudioPlayer.last_user_left(guild_id) (start idle timer)
```
`AudioPlayer.last_user_left/1` is the new single entry point for non-presence leave events. It:
- `:play` / `:presence`: calls `Voice.leave_channel` directly, clears state.
- `false`: calls `reset_idle_timeout` — starts the idle timer if `VOICE_IDLE_TIMEOUT_SECONDS > 0`, otherwise no-ops (bot stays indefinitely).
### User-Rejoin Cancel (false mode only)
`VoiceRuntime.handle_connect` gains a `false`-mode clause: when a non-bot user joins the bot's current channel, it calls `AudioPlayer.user_joined_channel/1`, which cancels the idle timer. This prevents the bot from leaving when a user briefly steps out and returns.
### Idle Timeout State Machine
```
AudioPlayer state: idle_timeout_ref = {timer_ref, token} | nil
play mode:
set_voice_channel({guild, chan}) → cancel old timer → schedule new timer
play_sound cast → cancel old timer → schedule new timer (reset)
set_voice_channel(nil, nil) → cancel timer
last_user_left → leave immediately, cancel timer
{:idle_timeout, token} → leave if token matches, else ignore (stale)
false mode:
last_user_left → schedule timer (if timeout > 0)
user_joined_channel → cancel timer
{:idle_timeout, token} → leave if token matches, else ignore (stale)
presence mode:
(no timer ever scheduled)
```
The `{ref, token}` pair guards against a race condition: if `Process.cancel_timer` returns `false` (the message already fired and is in the mailbox), the stale message arrives after a new timer is scheduled. The token mismatch causes it to be silently dropped rather than triggering a spurious leave.
### `IdleTimeoutPolicy` — Disabled State
`timeout_ms/0` now returns `nil` when `VOICE_IDLE_TIMEOUT_SECONDS <= 0` (previously always returned a positive integer). Callers treat `nil` as "disabled" and skip scheduling. This is how `false` mode achieves "never auto-leave" behavior.
### Direct `Voice.leave_channel` vs `VoiceCommands.leave_voice_channel`
`VoiceCommands.leave_voice_channel` would introduce a circular module dependency:
- `VoiceCommands` already calls `AudioPlayer.set_voice_channel` (compile-time dep)
- `AudioPlayer` calling `VoiceCommands` would close the cycle
`AudioPlayer` calls `Voice.leave_channel/1` directly and updates its own state. This is consistent with how `VoiceSession.maintain_connection` already calls `Voice.leave_channel` directly. The `:presence` path continues to use `VoiceCommands` via `VoiceRuntime` (no circular dep there).
### Actor Type Change
Previously, `play_sound` actors from the web layer were plain username strings (e.g. `"justin"`), and from the API layer, a map `%{display_name: username, user_id: db_id}`. Neither carried a `discord_id` usable for voice channel lookup.
Both callers now pass the full `%Soundboard.Accounts.User{}` struct. `PlaybackEngine` already handled this type (via `actor_display_name/1` and `actor_user_id/1` pattern matches), so no downstream changes were needed. The new `actor_discord_id/1` private function in `AudioPlayer` extracts the Discord ID for auto-join, and falls back to `nil` for strings and maps without `discord_id`.
---
## New Function: `VoicePresence.find_user_voice_channel/1`
```elixir
@spec find_user_voice_channel(String.t()) ::
{:ok, {guild_id :: String.t(), channel_id :: String.t()}} | :not_found
```
Iterates over all guilds in the EDA guild cache and returns the first voice state matching the given Discord user ID. Returns `:not_found` if the user is not in any voice channel or the cache is unavailable.
---
## New Module: `IdleTimeoutPolicy`
```
lib/soundboard/discord/handler/idle_timeout_policy.ex
```
Reads the `VOICE_IDLE_TIMEOUT_SECONDS` environment variable. Returns the timeout in milliseconds, or `nil` if the value is ≤ 0.
---
## Configuration
| Variable | Default | Description |
|---|---|---|
| `AUTO_JOIN` | `play` | Join/leave mode. `play` — join on sound playback, leave on idle or last user. `presence` — follow users in, leave when alone. `false` — manual only, leave after idle timeout once alone. |
| `VOICE_IDLE_TIMEOUT_SECONDS` | `600` | Seconds of inactivity before auto-leave. Set to `0` to disable. In `play` mode: resets on each sound played. In `false` mode: starts when last user leaves, cancels if a user rejoins. In `presence` mode: ignored. |
---
## Database Changes
**None.**
---
## File Inventory
| File | Action |
|---|---|
| `lib/soundboard/discord/handler/auto_join_policy.ex` | **Modify** — boolean → enum (`:presence`, `:play`, `false`); remove `enabled?/0` |
| `lib/soundboard/discord/handler/idle_timeout_policy.ex` | **New** — `VOICE_IDLE_TIMEOUT_SECONDS` config reader; returns `nil` when disabled |
| `lib/soundboard/discord/handler/voice_presence.ex` | **Modify** — add `find_user_voice_channel/1` |
| `lib/soundboard/discord/handler/voice_runtime.ex` | **Modify** — mode-aware connect/disconnect routing; `bot_alone_action/1`; `handle_user_rejoin_cancel/1` |
| `lib/soundboard/audio_player.ex` | **Modify** — mode-gated auto-join, idle timer, and last-user-left; new `last_user_left/1` and `user_joined_channel/1` public API |
| `lib/soundboard_web/live/support/sound_playback.ex` | **Modify** — pass `%User{}` struct instead of username string |
| `lib/soundboard_web/controllers/api/sound_controller.ex` | **Modify** — pass `%User{}` struct instead of display-name map |
| `test/soundboard/discord/handler/auto_join_policy_test.exs` | **Rewrite** — enum mode tests; removed `enabled?/0` tests |
| `test/soundboard/discord/handler/idle_timeout_policy_test.exs` | **New** — covers default, custom value, whitespace, disabled (0 and negative) |
| `test/soundboard/discord/handler/voice_presence_test.exs` | **New** — covers `find_user_voice_channel/1` |
| `test/soundboard/discord/handler/voice_runtime_test.exs` | **Modify** — updated mocks to `:presence`; new tests for `play`/`false` mode routing and user-rejoin cancel |
| `test/soundboard_web/audio_player_test.exs` | **Modify** — mode-gated idle timeout and auto-join tests; new `last_user_left` and `user_joined_channel` tests |
| `test/soundboard_web/discord_handler_test.exs` | **Modify** — presence-mode mock; updated leave-sequence assertion |
| `test/soundboard_web/plugs/api_auth_db_token_test.exs` | **Modify** — actor assertion updated |
| `test/soundboard_web/controllers/api/sound_controller_test.exs` | **Modify** — actor assertion updated |
| `test/soundboard_web/live/favorites_live_test.exs` | **Modify** — actor assertion updated |
---
## Testing Strategy
| Layer | What is tested |
|---|---|
| `AutoJoinPolicy` | Test env → `:play`. Default (no env var) → `:play`. `play` → `:play`. `presence`/truthy → `:presence`. `false`/falsy/unknown → `false`. |
| `IdleTimeoutPolicy` | Default → 600,000 ms. Custom value. Whitespace trimming. `0` → `nil`. Negative → `nil`. |
| `VoicePresence.find_user_voice_channel` | User found in a guild. User not found. User in guild but no channel. Multi-guild search. Cache unavailable. |
| `VoiceRuntime` | `handle_disconnect` notifies `AudioPlayer.last_user_left` in `:play` and `false` modes. `handle_connect` cancels idle timer via `AudioPlayer.user_joined_channel` in `false` mode. Bootstrap skips guild scan in `:play` mode. |
| `AudioPlayer` — idle timeout | Timer scheduled on `set_voice_channel` in `:play` mode only. Not scheduled in `:presence` or `false` mode. Timer cancelled on clear. Timer reset on `play_sound` in `:play` mode only (not reset in `:presence`). Timer fires → leave called, state cleared. Stale token ignored. |
| `AudioPlayer` — `last_user_left` | Leaves immediately in `:play` and `:presence` modes. Starts idle timer in `false` mode with timeout. No-ops in `false` mode with timeout disabled. Ignores call when bot is not in a channel. |
| `AudioPlayer` — `user_joined_channel` | Cancels idle timer. |
| `AudioPlayer` — auto-join | User with `discord_id` in a voice channel → join called, `voice_channel` set, idle timer started (`:play` mode). User not in any channel → error, no join. Actor without `discord_id` → no lookup attempted. Auto-join skipped in `false` mode. |
---
## Out of Scope
- **Auto-join for `!play` Discord commands**: the `!play` command handler already requires `!join` first; that flow was not changed.
- **Per-guild or per-channel idle timeout**: the timeout is global. Future work could make it configurable per guild via DB settings.
- **Idle timeout reset on playback *finish*** (as opposed to *start*): the timer resets when a sound is cast, not when it finishes playing. This means the clock starts as soon as playback is requested, not after the sound ends. For typical usage (sounds are 1–30 seconds) this makes no practical difference.
- **Notifying users before leaving**: the bot does not send a Discord message warning that it is about to leave due to inactivity.
- **`false` mode last-user-leave recheck delay**: the 1.5-second recheck-alone logic in `VoiceRuntime` applies for all modes, including `false`. The idle timer in `false` mode does not start until the recheck confirms the bot is alone.
================================================
FILE: docs/specs/youtube-playback.md
================================================
# Spec: YouTube Video Playback via Discord Bot Command
**Status:** Draft
**Date:** 2026-03-07
**Author:** —
---
## Summary
Add a `!play
` Discord bot command that extracts the audio from a YouTube video and plays it through the bot's current voice channel. This is an ephemeral, on-demand playback — it does **not** save the YouTube audio as a permanent sound in the library.
---
## Motivation
Users currently play pre-uploaded sounds (local files or direct URLs) via the soundboard. There is no way to quickly share and play a YouTube video's audio in voice chat without first downloading, converting, and uploading it. A `!play` command eliminates that friction.
---
## User-Facing Behavior
### Commands
| Command | Description |
|---|---|
| `!play ` | Extract audio from the YouTube URL and play it in the bot's current voice channel. |
| `!play ` | Same as above, with an explicit volume (0.0–1.5, default 1.0). |
| `!stop` | Stop whatever is currently playing (already exists — no change). |
### Responses
| Scenario | Bot Reply |
|---|---|
| Success | 🎵 Now playing: `` |
| Bot not in a voice channel | "I'm not in a voice channel. Use `!join` first." |
| Invalid / unsupported URL | "That doesn't look like a valid YouTube URL." |
| yt-dlp extraction fails | "Failed to fetch audio from that URL. It may be private, age-restricted, or region-locked." |
| Already playing (interrupt) | Stops current sound, starts YouTube audio (existing interrupt behavior). |
---
## Architecture & Design
### High-Level Flow
```
Discord message "!play "
│
▼
CommandHandler.handle_message/1 ← parse command, validate URL format
│
▼
Soundboard.YouTube.Extractor ← NEW module: call yt-dlp, return audio stream URL + metadata
│
▼
AudioPlayer.play_youtube/3 ← NEW public API on AudioPlayer GenServer
│
▼
PlaybackQueue / PlaybackEngine ← reuse existing queue & engine (plays URL type)
│
▼
Discord Voice (EDA) ← ffmpeg reads the stream URL, sends RTP
```
### New Modules
#### 1. `Soundboard.YouTube.Extractor`
Wraps the `yt-dlp` CLI directly via `System.cmd/3`.
**Responsibilities:**
- Validate that a URL is a supported YouTube link via a regex matching `youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`.
- Extract metadata + stream URL in a **single** `yt-dlp` invocation using combined flags: `yt-dlp --get-title --get-url --get-duration -f bestaudio --no-playlist `.
- Parse the multi-line stdout (line 1: title, line 2: stream URL, line 3: duration) into a struct.
- Enforce a maximum duration (configurable, default: 10 minutes / 600s) to prevent abuse.
- Wrap the call in `Task.async` + `Task.yield/2` with a configurable timeout (default: 15s) to avoid hanging on slow networks or unresponsive URLs.
- Return `{:ok, %{stream_url: url, title: title, duration_seconds: integer}}` or `{:error, reason}` with user-friendly messages derived from stderr.
**Key design decisions:**
- Always use the list-of-args form of `System.cmd/3` — never shell interpolation — to prevent command injection.
- `--no-playlist` flag to prevent accidentally queuing an entire playlist.
- `-f bestaudio` to get an audio-only stream URL that ffmpeg can consume directly.
- Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern.
- Availability check via `System.find_executable("yt-dlp")` or configured path, cached in `persistent_term` on first call.
- In tests, the module can be mocked via `Mox` or by making the system-cmd call go through a configurable function/module.
**Public API:**
```elixir
@spec extract(String.t()) :: {:ok, extraction()} | {:error, String.t()}
@spec valid_url?(String.t()) :: boolean()
@spec available?() :: boolean()
```
### Modified Modules
#### `Soundboard.Discord.Handler.CommandHandler`
Add a new clause:
```elixir
def handle_message(%{content: "!play " <> url_and_args} = msg)
```
- Parse the URL (first token) and optional volume (second token).
- Validate URL format via `YouTube.Extractor.valid_youtube_url?/1`.
- Check that the bot is in a voice channel (`AudioPlayer.current_voice_channel/0`).
- On validation pass, call `AudioPlayer.play_youtube(url, volume, actor)`.
- Reply with an appropriate Discord message (see table above).
#### `Soundboard.AudioPlayer` (GenServer)
Add a new public function and cast:
```elixir
def play_youtube(url, volume \\ 1.0, actor)
```
Internally sends `{:play_youtube, url, volume, actor}`. The `handle_cast` will:
1. Call `YouTube.Extractor.extract(url)`.
2. On success, build a play request (similar to `PlaybackQueue.build_request/3` but using the extracted stream URL and supplied volume directly, bypassing `SoundLibrary`).
3. Enqueue via `PlaybackQueue.enqueue/3` — reusing all existing interrupt/retry logic.
#### `Soundboard.AudioPlayer.PlaybackEngine`
No changes expected. The engine already supports `:url` play type, and the extracted stream URL is a direct audio URL that ffmpeg handles natively.
#### `Soundboard.AudioPlayer.PlaybackQueue`
Add a new `build_youtube_request/4` function (or extend `build_request`) that creates a play request from a raw URL + volume instead of looking up `SoundLibrary`:
```elixir
@spec build_youtube_request({String.t(), String.t()}, String.t(), number(), term()) ::
{:ok, play_request()}
def build_youtube_request({guild_id, channel_id}, stream_url, volume, actor)
```
The `sound_name` field in the request will be set to the video title (for display in notifications).
### Stats / Tracking
YouTube plays are **not** tracked in the `stats.plays` table (they aren't library sounds). The `PlaybackEngine` already skips tracking for system users — we can use a similar mechanism, or simply not call `track_play_if_needed` for YouTube plays. The `Notifier.sound_played/2` broadcast will still fire so the LiveView shows "User played ".
---
## Dependencies
### Hex
**None.** We wrap `yt-dlp` directly via `System.cmd/3` — no third-party Hex packages needed.
We evaluated `exyt_dlp` (~> 0.1.6) and decided against it. The library is a thin pass-through to `System.cmd("yt-dlp", params)` with no timeout support, no combined-flag calls, and opaque error handling (`:invalid_youtube_url_or_params` for everything). Our own wrapper is ~60 lines, gives us full control, and avoids a low-activity single-maintainer dependency.
### System: `yt-dlp`
`yt-dlp` is a **system dependency** that must be installed on the host.
| Environment | Installation |
|---|---|
| Local dev | `brew install yt-dlp` / `pip install yt-dlp` |
| Docker | Add `RUN pip install yt-dlp` (or grab the static binary) to the Dockerfile |
The application should gracefully degrade: if `yt-dlp` is not found, `!play` replies with "YouTube playback is not available (yt-dlp not installed)." Binary path resolved via `Application.get_env(:soundboard, :ytdlp_executable, :system)`, matching the existing `:ffmpeg_executable` pattern in `PlaybackEngine`.
---
## Configuration
Add to `config/config.exs` (or runtime config):
```elixir
config :soundboard, :ytdlp_executable, :system # :system | false | "/path/to/yt-dlp"
config :soundboard, :youtube_max_duration_seconds, 600 # 10 minutes
config :soundboard, :ytdlp_timeout_ms, 15_000 # extraction timeout
```
---
## Database Changes
**None.** YouTube plays are ephemeral and not persisted.
---
## File Inventory (new & changed)
| File | Action |
|---|---|
| `lib/soundboard/youtube/extractor.ex` | **New** — yt-dlp wrapper |
| `lib/soundboard/discord/handler/command_handler.ex` | **Modify** — add `!play` clause |
| `lib/soundboard/audio_player.ex` | **Modify** — add `play_youtube/3` cast |
| `lib/soundboard/audio_player/playback_queue.ex` | **Modify** — add `build_youtube_request/4` |
| `Dockerfile` | **Modify** — install `yt-dlp` |
| `config/config.exs` | **Modify** — add youtube config keys |
| `test/soundboard/youtube/extractor_test.exs` | **New** — covers extraction + URL validation |
| `test/soundboard/discord/handler/command_handler_test.exs` | **Modify** — add `!play` tests |
| `test/soundboard/audio_player_test.exs` | **Modify** — add youtube cast tests |
---
## Security Considerations
- **Input sanitization:** The YouTube URL is passed as an argument to `System.cmd/3`. Use the list-of-args form (`System.cmd("yt-dlp", [args...])`) — never shell interpolation — to prevent command injection.
- **Duration cap:** Enforce the max duration to prevent a user from streaming a 10-hour video and monopolizing the voice channel.
- **Rate limiting:** (Future / optional) Consider a per-user cooldown on `!play` to prevent spam. Not in scope for v1 but worth noting.
- **No disk writes:** The stream URL approach means no temp files accumulate on the server.
---
## Testing Strategy
| Layer | What to test |
|---|---|
| `YouTube.Extractor` | URL validation (valid/invalid/edge cases: `watch?v=`, `youtu.be/`, shorts, playlist URLs rejected, non-YouTube rejected). Mock `System.cmd` to test parse logic for yt-dlp stdout. Timeout handling. Duration enforcement. Missing binary. |
| `CommandHandler` | `!play` with valid URL dispatches to `AudioPlayer`. `!play` with garbage URL returns error message. `!play` with no args returns usage hint. Bot not in channel returns error. |
| `AudioPlayer` | `play_youtube` cast flows through to `PlaybackQueue`. Integration with mock voice. |
| Manual / integration | End-to-end: bot in voice → `!play https://youtu.be/dQw4w9WgXcQ` → audio plays in Discord. |
---
## Out of Scope (future enhancements)
- Saving a YouTube sound to the library permanently ("!save" command).
- Queue / playlist support (multiple `!play` commands queued in order).
- Playback controls (`!pause`, `!resume`, `!skip`).
- Playing from other platforms (SoundCloud, Spotify, etc.).
- Web UI integration (play YouTube from the LiveView).
- Now-playing status / progress indicator in Discord or the web UI.
---
## Open Questions
1. **Should `!play` also accept non-YouTube URLs?** yt-dlp supports hundreds of sites. We could allow any yt-dlp-supported URL, or restrict to YouTube only for v1. Restricting is simpler and safer — recommend YouTube-only for now.
2. **Should the extraction happen in the `CommandHandler` (before casting to `AudioPlayer`) or inside the `AudioPlayer` GenServer?** Doing it in a Task spawned by `CommandHandler` keeps the AudioPlayer GenServer responsive. However, the current flow already uses `Task.async` inside `PlaybackQueue.start_playback/2`, so doing extraction inside the AudioPlayer cast is consistent. **Recommendation:** Extract in the AudioPlayer cast (inside the spawned playback Task) so the command handler remains fast and the reply can be sent immediately ("⏳ Fetching audio…").
3. **Max duration default?** 10 minutes seems reasonable. Should this be configurable per-guild or global? **Recommendation:** Global config for v1.
================================================
FILE: entrypoint.sh
================================================
#!/bin/sh
# Run migrations
echo "Running database migrations..."
mix ecto.migrate
# Start Phoenix server in foreground
# Using exec ensures proper signal handling and process management
echo "Starting Phoenix server..."
exec mix phx.server
================================================
FILE: lib/soundboard/accounts/api_token.ex
================================================
defmodule Soundboard.Accounts.ApiToken do
@moduledoc """
API access token bound to a user.
The token hash is used for verification. The plaintext token is also persisted
so the Settings UI can display and copy active tokens after creation.
"""
use Ecto.Schema
import Ecto.Changeset
alias Soundboard.Accounts.User
@type t :: %__MODULE__{
id: integer() | nil,
user_id: integer() | nil,
user: User.t() | Ecto.Association.NotLoaded.t() | nil,
token_hash: String.t() | nil,
token: String.t() | nil,
label: String.t() | nil,
revoked_at: NaiveDateTime.t() | nil,
last_used_at: NaiveDateTime.t() | nil,
inserted_at: NaiveDateTime.t() | nil,
updated_at: NaiveDateTime.t() | nil
}
schema "api_tokens" do
belongs_to :user, User
field :token_hash, :string
field :token, :string
field :label, :string
field :revoked_at, :naive_datetime
field :last_used_at, :naive_datetime
timestamps()
end
def changeset(token, attrs) do
token
|> cast(attrs, [:user_id, :token_hash, :token, :label, :revoked_at, :last_used_at])
|> validate_required([:user_id, :token_hash])
|> unique_constraint(:token_hash)
|> assoc_constraint(:user)
end
end
================================================
FILE: lib/soundboard/accounts/api_tokens.ex
================================================
defmodule Soundboard.Accounts.ApiTokens do
@moduledoc """
Context for managing API tokens bound to users.
"""
require Logger
import Ecto.Query
alias Soundboard.Accounts.{ApiToken, User}
alias Soundboard.Repo
@type verify_error :: :invalid | :token_update_failed
@type verify_result :: {:ok, User.t(), ApiToken.t()} | {:error, verify_error}
@type revoke_result ::
{:ok, ApiToken.t()} | {:error, :forbidden | :not_found | Ecto.Changeset.t()}
@prefix "sb_"
@spec list_tokens(User.t()) :: [ApiToken.t()]
def list_tokens(%User{id: user_id}) do
from(t in ApiToken,
where: t.user_id == ^user_id and is_nil(t.revoked_at),
order_by: [desc: t.inserted_at]
)
|> Repo.all()
end
@spec generate_token(User.t(), map()) ::
{:ok, String.t(), ApiToken.t()} | {:error, Ecto.Changeset.t()}
def generate_token(%User{id: user_id}, attrs \\ %{}) do
raw = random_token()
hash = hash_token(raw)
changeset =
%ApiToken{}
|> ApiToken.changeset(%{
user_id: user_id,
token_hash: hash,
token: raw,
label: Map.get(attrs, "label") || Map.get(attrs, :label)
})
case Repo.insert(changeset) do
{:ok, token} -> {:ok, raw, token}
{:error, changeset} -> {:error, changeset}
end
end
@spec verify_token(String.t()) :: verify_result()
def verify_token(raw) when is_binary(raw) do
query =
from t in ApiToken,
where: t.token_hash == ^hash_token(raw) and is_nil(t.revoked_at)
case Repo.one(query) do
nil ->
{:error, :invalid}
token ->
token = Repo.preload(token, :user)
case update_last_used_at(token) do
{:ok, _updated_token} ->
{:ok, token.user, token}
{:error, changeset} ->
Logger.error("Failed to update API token last_used_at: #{inspect(changeset.errors)}")
{:error, :token_update_failed}
end
end
end
@spec revoke_token(User.t(), integer() | String.t()) :: revoke_result()
def revoke_token(%User{id: user_id}, token_id) do
token_id = normalize_id(token_id)
case Repo.get(ApiToken, token_id) do
%ApiToken{user_id: ^user_id} = token ->
token
|> Ecto.Changeset.change(
revoked_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
)
|> Repo.update()
%ApiToken{} ->
{:error, :forbidden}
nil ->
{:error, :not_found}
end
end
defp update_last_used_at(%ApiToken{} = token) do
token
|> Ecto.Changeset.change(
last_used_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
)
|> Repo.update()
end
defp random_token do
@prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
end
defp hash_token(raw) do
:crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
end
defp normalize_id(id) when is_integer(id), do: id
defp normalize_id(id) when is_binary(id) do
case Integer.parse(id) do
{int, ""} -> int
_ -> -1
end
end
end
================================================
FILE: lib/soundboard/accounts/user.ex
================================================
defmodule Soundboard.Accounts.User do
@moduledoc """
The User module.
"""
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer() | nil,
discord_id: String.t() | nil,
username: String.t() | nil,
avatar: String.t() | nil,
inserted_at: NaiveDateTime.t() | nil,
updated_at: NaiveDateTime.t() | nil
}
schema "users" do
field :discord_id, :string
field :username, :string
field :avatar, :string
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:discord_id, :username, :avatar])
|> validate_required([:discord_id, :username])
|> unique_constraint(:discord_id)
end
end
================================================
FILE: lib/soundboard/accounts.ex
================================================
defmodule Soundboard.Accounts do
@moduledoc """
Accounts boundary helpers used by web and runtime code.
"""
alias Soundboard.Accounts.User
alias Soundboard.Repo
import Ecto.Query
def get_user(user_id), do: Repo.get(User, user_id)
def avatars_by_usernames([]), do: %{}
def avatars_by_usernames(usernames) when is_list(usernames) do
from(u in User, where: u.username in ^usernames, select: {u.username, u.avatar})
|> Repo.all()
|> Map.new()
end
end
================================================
FILE: lib/soundboard/application.ex
================================================
defmodule Soundboard.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
alias Soundboard.Discord.RuntimeCapability
require Logger
@impl true
def start(_type, _args) do
Logger.info("Starting Soundboard Application")
children = [
Soundboard.Repo,
{Soundboard.AudioPlayer, []},
SoundboardWeb.Telemetry,
{Phoenix.PubSub, name: Soundboard.PubSub},
SoundboardWeb.Presence,
SoundboardWeb.PresenceHandler,
Soundboard.Discord.Handler.State,
SoundboardWeb.Endpoint
| discord_children()
]
opts = [strategy: :one_for_one, name: Soundboard.Supervisor]
Supervisor.start_link(children, opts)
end
defp discord_children do
if RuntimeCapability.discord_handler_enabled?() do
[Soundboard.Discord.Handler]
else
RuntimeCapability.log_degraded_mode()
[]
end
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
SoundboardWeb.Endpoint.config_change(changed, removed)
:ok
end
end
================================================
FILE: lib/soundboard/audio_player/notifier.ex
================================================
defmodule Soundboard.AudioPlayer.Notifier do
@moduledoc false
alias Soundboard.PubSubTopics
def sound_played(sound_name, actor_name) do
PubSubTopics.broadcast_sound_played(sound_name, actor_name)
end
def error(message) do
PubSubTopics.broadcast_error(message)
end
end
================================================
FILE: lib/soundboard/audio_player/playback_engine.ex
================================================
defmodule Soundboard.AudioPlayer.PlaybackEngine do
@moduledoc false
require Logger
alias Soundboard.Accounts.User
alias Soundboard.{AudioPlayer, AudioPlayer.Notifier, AudioPlayer.SoundLibrary, Discord.Voice}
@system_users ["System"]
@rtp_probe_poll_ms 20
@rtp_probe_default_timeout_ms 6_000
@voice_not_ready_retry_ms 350
@voice_ready_poll_ms 100
@voice_ready_timeout_ms 4_000
@voice_ready_fast_timeout_ms 1_200
@voice_settle_ms 120
@rejoin_retry_threshold 3
@max_play_attempts 20
def play(guild_id, channel_id, sound_name, path_or_url, volume, actor) do
join_state = ensure_joined_channel(guild_id, channel_id)
maybe_settle_before_play(join_state)
submit_play_request(guild_id, sound_name, path_or_url, volume, actor)
end
defp maybe_settle_before_play({:joined, :ok}) do
Process.sleep(@voice_settle_ms)
end
defp maybe_settle_before_play(_), do: :ok
defp submit_play_request(guild_id, sound_name, path_or_url, volume, actor) do
if is_nil(ffmpeg_executable()) do
Logger.error("ffmpeg not found in PATH. Cannot play #{sound_name}")
broadcast_error("ffmpeg is not installed on this host")
:error
else
{play_input, play_type} = SoundLibrary.prepare_play_input(sound_name, path_or_url)
play_request = %{
guild_id: guild_id,
play_input: play_input,
play_type: play_type,
play_options: [volume: clamp_volume(volume)],
sound_name: sound_name,
actor: actor
}
play_with_retries(play_request, 0, false)
end
end
defp play_with_retries(play_request, attempt, refresh_attempted)
when attempt < @max_play_attempts do
case voice_play(play_request) |> classify_play_attempt() do
:ok ->
maybe_probe_first_rtp(play_request.guild_id, play_request.sound_name, attempt + 1)
track_play_if_needed(play_request.sound_name, play_request.actor)
broadcast_success(play_request.sound_name, play_request.actor)
:ok
{:retry, retry} ->
retry_play_attempt(play_request, attempt, refresh_attempted, retry)
{:error, reason} ->
Logger.error("Voice.play failed: #{inspect(reason)} (attempt #{attempt + 1})")
broadcast_error("Failed to play sound: #{reason}")
:error
end
end
defp play_with_retries(%{sound_name: sound_name}, attempt, _refresh_attempted) do
Logger.error("Exceeded max retries (#{attempt}) for playing #{sound_name}")
broadcast_error("Failed to play sound after multiple attempts")
:error
end
defp voice_play(play_request) do
Voice.play(
play_request.guild_id,
play_request.play_input,
play_request.play_type,
play_request.play_options
)
end
defp classify_play_attempt(:ok), do: :ok
defp classify_play_attempt({:error, "Audio already playing in voice channel."}) do
{:retry,
%{
log: "Audio still playing, stopping and retrying...",
sleep_ms: 50,
stop_first?: true,
force_refresh?: false
}}
end
defp classify_play_attempt({:error, "Must be connected to voice channel to play audio."}) do
{:retry,
%{
log: "Voice reported not connected, waiting before retry...",
sleep_ms: @voice_not_ready_retry_ms,
stop_first?: false,
force_refresh?: false
}}
end
defp classify_play_attempt({:error, "Voice session is still negotiating encryption."}) do
{:retry,
%{
log:
"Voice encryption not ready yet, waiting #{@voice_not_ready_retry_ms}ms before retry...",
sleep_ms: @voice_not_ready_retry_ms,
stop_first?: false,
force_refresh?: true
}}
end
defp classify_play_attempt({:error, reason}), do: {:error, reason}
defp classify_play_attempt(other), do: {:error, inspect(other)}
defp retry_play_attempt(play_request, attempt, refresh_attempted, retry) do
Logger.warning("#{retry.log} (attempt #{attempt + 1})")
if retry.stop_first? do
Voice.stop(play_request.guild_id)
end
refresh_attempted =
maybe_trigger_rejoin(
play_request.guild_id,
attempt,
refresh_attempted,
retry.force_refresh?
)
Process.sleep(retry.sleep_ms)
play_with_retries(play_request, attempt + 1, refresh_attempted)
end
defp maybe_trigger_rejoin(guild_id, attempt, refresh_attempted, force_refresh) do
if attempt >= @rejoin_retry_threshold and not refresh_attempted do
maybe_rejoin_current_channel(guild_id, force_refresh)
true
else
refresh_attempted
end
end
defp maybe_rejoin_current_channel(guild_id, force_refresh) do
case AudioPlayer.current_voice_channel() do
{:ok, {^guild_id, channel_id}} ->
maybe_rejoin_for_channel(guild_id, channel_id, force_refresh)
{:ok, _other_channel} ->
:ok
{:error, reason} ->
Logger.debug("Skipping rejoin lookup for guild #{guild_id}: #{inspect(reason)}")
:ok
end
:ok
end
defp maybe_rejoin_for_channel(guild_id, channel_id, true) do
joined? = Voice.channel_id(guild_id) == to_string(channel_id)
ready? = match?({:ok, true}, safe_voice_ready(guild_id))
cond do
joined? and not ready? ->
refresh_voice_session(guild_id, channel_id)
joined? and ready? ->
Logger.debug("Skipping refresh; voice already ready in channel #{channel_id}")
true ->
rejoin_voice_channel(guild_id, channel_id)
end
end
defp maybe_rejoin_for_channel(guild_id, channel_id, false) do
joined? = Voice.channel_id(guild_id) == to_string(channel_id)
ready? = match?({:ok, true}, safe_voice_ready(guild_id))
if joined? and ready? do
Logger.debug("Skipping rejoin; already in voice channel #{channel_id}")
else
rejoin_voice_channel(guild_id, channel_id)
end
end
defp refresh_voice_session(guild_id, channel_id) do
reconnect_voice_session(
guild_id,
channel_id,
"Refreshing voice session in channel #{channel_id} with in-place rejoin"
)
end
defp rejoin_voice_channel(guild_id, channel_id) do
reconnect_voice_session(guild_id, channel_id, "Rejoining voice channel #{channel_id}")
end
defp reconnect_voice_session(guild_id, channel_id, log_message) do
Logger.info(log_message)
Voice.join_channel(guild_id, channel_id)
wait_for_voice_ready(guild_id)
end
defp ensure_joined_channel(guild_id, channel_id) do
if Voice.channel_id(guild_id) == to_string(channel_id) do
{:already_joined, wait_for_voice_ready(guild_id, @voice_ready_fast_timeout_ms)}
else
Logger.info("Joining voice channel #{channel_id}")
Voice.join_channel(guild_id, channel_id)
Process.sleep(150)
{:joined, wait_for_voice_ready(guild_id)}
end
end
defp maybe_probe_first_rtp(guild_id, sound_name, attempt_number) do
if Application.get_env(:soundboard, :voice_rtp_probe, false) do
timeout_ms =
Application.get_env(
:soundboard,
:voice_rtp_probe_timeout_ms,
@rtp_probe_default_timeout_ms
)
initial_seq = current_rtp_sequence(guild_id)
started_at = System.monotonic_time(:millisecond)
Task.start(fn ->
wait_for_first_rtp(
guild_id,
sound_name,
attempt_number,
initial_seq,
started_at,
timeout_ms
)
end)
end
:ok
end
defp wait_for_first_rtp(
guild_id,
sound_name,
attempt_number,
initial_seq,
started_at,
timeout_ms
) do
elapsed_ms = System.monotonic_time(:millisecond) - started_at
current_seq = current_rtp_sequence(guild_id)
initial_seq_value = unwrap_sequence(initial_seq)
current_seq_value = unwrap_sequence(current_seq)
cond do
is_integer(initial_seq_value) and is_integer(current_seq_value) and
current_seq_value != initial_seq_value ->
Logger.info(
"RTP probe: first packet for #{sound_name} after #{elapsed_ms}ms " <>
"(attempt #{attempt_number}, seq #{initial_seq_value} -> #{current_seq_value})"
)
is_nil(initial_seq_value) and is_integer(current_seq_value) ->
Logger.info(
"RTP probe: sequence initialized for #{sound_name} after #{elapsed_ms}ms " <>
"(attempt #{attempt_number}, seq #{current_seq_value})"
)
elapsed_ms >= timeout_ms ->
status = safe_voice_status(guild_id)
Logger.warning(
"RTP probe: no progress for #{sound_name} within #{timeout_ms}ms " <>
"(attempt #{attempt_number}, initial_seq=#{inspect(initial_seq)}, " <>
"current_seq=#{inspect(current_seq)}, channel=#{inspect(status.channel)}, " <>
"playing=#{inspect(status.playing)})"
)
true ->
Process.sleep(@rtp_probe_poll_ms)
wait_for_first_rtp(
guild_id,
sound_name,
attempt_number,
initial_seq,
started_at,
timeout_ms
)
end
end
defp wait_for_voice_ready(guild_id, timeout_ms \\ @voice_ready_timeout_ms) do
started_at = System.monotonic_time(:millisecond)
do_wait_for_voice_ready(guild_id, started_at, timeout_ms)
end
defp do_wait_for_voice_ready(guild_id, started_at, timeout_ms) do
cond do
match?({:ok, true}, safe_voice_ready(guild_id)) ->
:ok
System.monotonic_time(:millisecond) - started_at >= timeout_ms ->
Logger.warning(
"Timed out waiting for voice readiness in guild #{guild_id} " <>
"(channel=#{inspect(safe_voice_channel(guild_id))})"
)
:timeout
true ->
Process.sleep(@voice_ready_poll_ms)
do_wait_for_voice_ready(guild_id, started_at, timeout_ms)
end
end
defp current_rtp_sequence(guild_id) do
case Voice.get_voice(guild_id) do
{:ok, %{rtp_sequence: seq}} when is_integer(seq) -> {:ok, seq}
{:ok, _state} -> {:ok, nil}
{:error, reason} -> {:error, {:voice_state_unavailable, reason}}
end
rescue
error -> {:error, {:voice_state_unavailable, Exception.message(error)}}
end
defp safe_voice_status(guild_id) do
%{
channel: safe_voice_channel(guild_id),
playing: safe_voice_playing(guild_id)
}
end
defp safe_voice_ready(guild_id) do
{:ok, Voice.ready?(guild_id)}
rescue
error -> {:error, {:voice_not_ready, Exception.message(error)}}
end
defp safe_voice_channel(guild_id) do
{:ok, Voice.channel_id(guild_id)}
rescue
error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}
end
defp safe_voice_playing(guild_id) do
{:ok, Voice.playing?(guild_id)}
rescue
error -> {:error, {:voice_playback_unavailable, Exception.message(error)}}
end
defp track_play_if_needed(sound_name, actor) do
cond do
system_user?(actor) ->
:ok
is_integer(actor_user_id(actor)) ->
Soundboard.Stats.track_play(sound_name, actor_user_id(actor))
is_binary(actor_display_name(actor)) ->
username = actor_display_name(actor)
case Soundboard.Repo.get_by(User, username: username) do
%{id: user_id} -> Soundboard.Stats.track_play(sound_name, user_id)
nil -> Logger.warning("Could not find user_id for #{username}")
end
true ->
Logger.warning("Could not determine playback actor for #{sound_name}")
end
end
defp broadcast_success(sound_name, actor) do
Notifier.sound_played(sound_name, actor_display_name(actor) || "Unknown")
end
defp broadcast_error(message) do
Notifier.error(message)
end
defp unwrap_sequence({:ok, sequence}), do: sequence
defp unwrap_sequence({:error, _reason}), do: nil
defp ffmpeg_executable do
case Application.get_env(:soundboard, :ffmpeg_executable, :system) do
:system -> System.find_executable("ffmpeg")
false -> nil
path when is_binary(path) -> path
end
end
defp clamp_volume(value) when is_number(value) do
value
|> max(0.0)
|> min(1.5)
|> Float.round(4)
end
defp clamp_volume(_), do: 1.0
defp actor_display_name(%{display_name: display_name}) when is_binary(display_name),
do: display_name
defp actor_display_name(%User{username: username}) when is_binary(username), do: username
defp actor_display_name(username) when is_binary(username), do: username
defp actor_display_name(_), do: nil
defp actor_user_id(%{user_id: user_id}) when is_integer(user_id), do: user_id
defp actor_user_id(%User{id: user_id}) when is_integer(user_id), do: user_id
defp actor_user_id(_), do: nil
defp system_user?(actor), do: actor_display_name(actor) in @system_users
end
================================================
FILE: lib/soundboard/audio_player/playback_queue.ex
================================================
defmodule Soundboard.AudioPlayer.PlaybackQueue do
@moduledoc false
require Logger
alias Soundboard.AudioPlayer.{PlaybackEngine, SoundLibrary, State}
alias Soundboard.Discord.Voice
@type play_request :: %{
guild_id: String.t(),
channel_id: String.t(),
sound_name: String.t(),
path_or_url: String.t(),
volume: number(),
actor: term()
}
@spec build_request({String.t(), String.t()}, String.t(), term()) ::
{:ok, play_request()} | {:error, String.t()}
def build_request({guild_id, channel_id}, sound_name, actor) do
case SoundLibrary.get_sound_path(sound_name) do
{:ok, {path_or_url, volume}} ->
{:ok,
%{
guild_id: guild_id,
channel_id: channel_id,
sound_name: sound_name,
path_or_url: path_or_url,
volume: volume,
actor: actor
}}
{:error, reason} ->
{:error, reason}
end
end
@spec enqueue(State.t(), play_request(), pos_integer()) :: State.t()
def enqueue(%State{} = state, request, interrupt_watchdog_ms) do
case state.current_playback do
nil ->
state
|> cancel_interrupt_watchdog()
|> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})
|> start_playback(request)
_ ->
state
|> Map.put(:pending_request, request)
|> maybe_interrupt_current(interrupt_watchdog_ms)
end
end
@spec clear_all(State.t()) :: State.t()
def clear_all(%State{} = state) do
state
|> clear_current_playback()
|> Map.merge(%{
pending_request: nil,
interrupting: false,
interrupt_watchdog_attempt: 0
})
end
@spec handle_task_result(State.t(), term()) :: State.t()
def handle_task_result(
%State{current_playback: %{sound_name: sound_name} = current} = state,
result
) do
case result do
:ok ->
%{
state
| current_playback:
current
|> Map.put(:task_ref, nil)
|> Map.put(:task_pid, nil)
}
:error ->
Logger.error("Playback start failed for #{sound_name}")
state |> clear_current_playback() |> maybe_start_pending()
end
end
@spec handle_task_down(State.t(), term()) :: State.t()
def handle_task_down(%State{} = state, reason) do
Logger.error("Playback task crashed: #{inspect(reason)}")
state |> clear_current_playback() |> maybe_start_pending()
end
@spec handle_interrupt_watchdog(
State.t(),
String.t(),
non_neg_integer(),
pos_integer(),
pos_integer()
) ::
State.t()
def handle_interrupt_watchdog(
%State{interrupting: true, interrupt_watchdog_attempt: attempt} = state,
guild_id,
attempt,
max_attempts,
interrupt_watchdog_ms
) do
cond do
state.current_playback == nil ->
state |> reset_interrupt_state() |> maybe_start_pending()
attempt >= max_attempts ->
Logger.warning(
"Interrupt watchdog timed out for guild #{guild_id}; forcing latest request"
)
Voice.stop(guild_id)
state |> clear_current_playback() |> maybe_start_pending()
match?({:ok, true}, safe_voice_playing(guild_id)) ->
Logger.debug(
"Interrupt watchdog: audio still playing in guild #{guild_id}, retrying stop"
)
Voice.stop(guild_id)
schedule_interrupt_watchdog(state, guild_id, attempt + 1, interrupt_watchdog_ms)
true ->
Logger.debug("Interrupt watchdog: playback already stopped for guild #{guild_id}")
state |> clear_current_playback() |> maybe_start_pending()
end
end
def handle_interrupt_watchdog(%State{} = state, _guild_id, _attempt, _max_attempts, _delay_ms),
do: state
@spec handle_playback_finished(State.t(), String.t()) :: State.t()
def handle_playback_finished(%State{} = state, guild_id) do
cond do
match?(%{guild_id: ^guild_id}, state.current_playback) ->
state
|> clear_current_playback()
|> maybe_start_pending()
state.interrupting and match?({^guild_id, _}, state.voice_channel) ->
state
|> reset_interrupt_state()
|> maybe_start_pending()
true ->
state
end
end
defp start_playback(state, request) do
task =
Task.async(fn ->
PlaybackEngine.play(
request.guild_id,
request.channel_id,
request.sound_name,
request.path_or_url,
request.volume,
request.actor
)
end)
%{
state
| current_playback: request |> Map.put(:task_ref, task.ref) |> Map.put(:task_pid, task.pid)
}
end
defp maybe_interrupt_current(%State{current_playback: %{guild_id: guild_id}} = state, delay_ms) do
Logger.debug("Interrupting current playback in guild #{guild_id} for latest request")
Voice.stop(guild_id)
if match?({:ok, true}, safe_voice_playing(guild_id)) do
state
|> Map.put(:interrupting, true)
|> schedule_interrupt_watchdog(guild_id, 1, delay_ms)
else
Logger.debug("Interrupt fast-path: playback stopped immediately in guild #{guild_id}")
state
|> clear_current_playback()
|> maybe_start_pending()
end
end
defp maybe_interrupt_current(%State{} = state, _delay_ms), do: state
defp maybe_start_pending(%State{pending_request: nil} = state), do: state
defp maybe_start_pending(%State{} = state) do
request = state.pending_request
case state.voice_channel do
{guild_id, channel_id}
when guild_id == request.guild_id and channel_id == request.channel_id ->
state
|> Map.put(:pending_request, nil)
|> start_playback(request)
_ ->
%{state | pending_request: nil}
end
end
defp clear_current_playback(%State{} = state) do
cancel_playback_task(state.current_playback)
state
|> cancel_interrupt_watchdog()
|> Map.merge(%{
current_playback: nil,
interrupting: false,
interrupt_watchdog_attempt: 0
})
end
defp reset_interrupt_state(%State{} = state) do
state
|> cancel_interrupt_watchdog()
|> Map.merge(%{interrupting: false, interrupt_watchdog_attempt: 0})
end
defp schedule_interrupt_watchdog(%State{} = state, guild_id, attempt, delay_ms) do
state = cancel_interrupt_watchdog(state)
ref = Process.send_after(self(), {:interrupt_watchdog, guild_id, attempt}, delay_ms)
%{state | interrupt_watchdog_ref: ref, interrupt_watchdog_attempt: attempt}
end
defp cancel_interrupt_watchdog(%State{interrupt_watchdog_ref: nil} = state), do: state
defp cancel_interrupt_watchdog(%State{} = state) do
Process.cancel_timer(state.interrupt_watchdog_ref)
%{state | interrupt_watchdog_ref: nil}
end
defp cancel_playback_task(nil), do: :ok
defp cancel_playback_task(%{task_pid: pid, task_ref: ref}) when is_pid(pid) do
if is_reference(ref), do: Process.demonitor(ref, [:flush])
if Process.alive?(pid) do
Process.exit(pid, :kill)
end
:ok
end
defp cancel_playback_task(_), do: :ok
defp safe_voice_playing(guild_id) do
{:ok, Voice.playing?(guild_id)}
rescue
error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}
end
end
================================================
FILE: lib/soundboard/audio_player/sound_library.ex
================================================
defmodule Soundboard.AudioPlayer.SoundLibrary do
@moduledoc false
require Logger
alias Soundboard.Sound
def ensure_cache do
case :ets.info(:sound_meta_cache) do
:undefined ->
:ets.new(:sound_meta_cache, [:set, :named_table, :public, read_concurrency: true])
:ok
_ ->
:ok
end
end
def get_sound_path(sound_name) do
ensure_cache()
case lookup_cached_sound(sound_name) do
{:hit, {_type, input, volume}} -> {:ok, {input, volume}}
:miss -> resolve_and_cache_sound(sound_name)
end
end
def prepare_play_input(sound_name, path_or_url) do
ensure_cache()
case :ets.lookup(:sound_meta_cache, sound_name) do
[{^sound_name, %{source_type: source_type}}] when source_type in ["url", "local"] ->
{path_or_url, :url}
_ ->
case Soundboard.Repo.get_by(Sound, filename: sound_name) do
%{source_type: source_type} when source_type in ["url", "local"] ->
{path_or_url, :url}
_ ->
Logger.warning("Unknown source type for #{sound_name}; defaulting to direct playback")
{path_or_url, :url}
end
end
end
@doc """
Removes any cached metadata for the given `sound_name` so future plays use fresh data.
"""
def invalidate_cache(sound_name) when is_binary(sound_name) do
ensure_cache()
:ets.delete(:sound_meta_cache, sound_name)
:ok
end
def invalidate_cache(_), do: :ok
defp lookup_cached_sound(sound_name) do
case :ets.lookup(:sound_meta_cache, sound_name) do
[{^sound_name, %{source_type: source, input: input, volume: volume}}] ->
{:hit, {source, input, volume}}
_ ->
:miss
end
end
defp resolve_and_cache_sound(sound_name) do
case Soundboard.Repo.get_by(Sound, filename: sound_name) do
nil ->
Logger.error("Sound not found in database: #{sound_name}")
{:error, "Sound not found"}
%{source_type: "url", url: url, volume: volume} when is_binary(url) ->
meta = %{source_type: "url", input: url, volume: volume || 1.0}
cache_sound(sound_name, meta)
{:ok, {meta.input, meta.volume}}
%{source_type: "local", filename: filename, volume: volume} when is_binary(filename) ->
path = resolve_upload_path(filename)
if File.exists?(path) do
meta = %{source_type: "local", input: path, volume: volume || 1.0}
cache_sound(sound_name, meta)
{:ok, {meta.input, meta.volume}}
else
Logger.error("Local file not found: #{path}")
{:error, "Sound file not found at #{path}"}
end
_sound ->
Logger.error("Invalid sound configuration for #{sound_name}")
{:error, "Invalid sound configuration"}
end
end
defp resolve_upload_path(filename) do
Soundboard.UploadsPath.file_path(filename)
end
defp cache_sound(sound_name, meta) do
:ets.insert(:sound_meta_cache, {sound_name, meta})
end
end
================================================
FILE: lib/soundboard/audio_player/voice_session.ex
================================================
defmodule Soundboard.AudioPlayer.VoiceSession do
@moduledoc false
require Logger
alias Soundboard.AudioPlayer.State
alias Soundboard.Discord.Voice
@spec normalize_channel(term(), term()) :: {String.t(), String.t()} | nil
def normalize_channel(guild_id, channel_id) do
if is_nil(guild_id) or is_nil(channel_id) do
nil
else
{guild_id, channel_id}
end
end
@spec maintain_connection(State.t()) :: State.t()
def maintain_connection(%State{voice_channel: {guild_id, channel_id}} = state)
when not is_nil(guild_id) and not is_nil(channel_id) do
guild_id
|> maintenance_status(channel_id)
|> perform_maintenance(state)
end
def maintain_connection(%State{} = state), do: state
defp maintenance_status(guild_id, channel_id) do
%{
guild_id: guild_id,
channel_id: channel_id,
joined?: Voice.channel_id(guild_id) == to_string(channel_id),
ready?: voice_ready(guild_id),
playing?: voice_playing(guild_id)
}
end
defp voice_ready(guild_id) do
case safe_voice_ready(guild_id) do
{:ok, value} ->
value
{:error, reason} ->
Logger.warning("Voice readiness unavailable for guild #{guild_id}: #{inspect(reason)}")
false
end
end
defp voice_playing(guild_id) do
case safe_voice_playing(guild_id) do
{:ok, value} ->
value
{:error, reason} ->
Logger.warning(
"Voice playback status unavailable for guild #{guild_id}: #{inspect(reason)}; continuing maintenance"
)
false
end
end
defp perform_maintenance(%{playing?: true}, state), do: state
defp perform_maintenance(%{joined?: true, ready?: true}, state), do: state
defp perform_maintenance(%{joined?: true} = status, state) do
Logger.warning(
"Voice session unready for guild #{status.guild_id} in channel #{status.channel_id}, forcing leave→rejoin"
)
try do
Voice.leave_channel(status.guild_id)
rescue
error -> Logger.warning("Voice leave failed during reset: #{inspect(error)}")
end
Process.sleep(1_000)
attempt_voice_join(state, status.guild_id, status.channel_id, "rejoin after stale session")
end
defp perform_maintenance(status, state) do
Logger.warning(
"Voice channel mismatch for guild #{status.guild_id}, attempting to rejoin #{status.channel_id}"
)
attempt_voice_join(state, status.guild_id, status.channel_id, "rejoin")
end
defp attempt_voice_join(state, guild_id, channel_id, action) do
case safe_join_voice_channel(guild_id, channel_id) do
:ok ->
state
{:error, reason} ->
Logger.error("Failed to #{action} voice channel: #{inspect(reason)}")
%{state | voice_channel: nil}
end
end
defp safe_voice_ready(guild_id) do
{:ok, Voice.ready?(guild_id)}
rescue
error -> {:error, {:voice_not_ready, Exception.message(error)}}
end
defp safe_voice_playing(guild_id) do
{:ok, Voice.playing?(guild_id)}
rescue
error -> {:error, {:voice_playing_unavailable, Exception.message(error)}}
end
defp safe_join_voice_channel(guild_id, channel_id) do
Voice.join_channel(guild_id, channel_id)
:ok
rescue
error -> {:error, {:voice_join_failed, Exception.message(error)}}
catch
:exit, reason -> {:error, {:voice_join_failed, reason}}
end
end
================================================
FILE: lib/soundboard/audio_player.ex
================================================
defmodule Soundboard.AudioPlayer do
@moduledoc """
Handles audio playback coordination.
"""
use GenServer
require Logger
alias Soundboard.Accounts.User
alias Soundboard.AudioPlayer.{Notifier, PlaybackQueue, SoundLibrary, VoiceSession}
alias Soundboard.Discord.Handler.{AutoJoinPolicy, IdleTimeoutPolicy, VoicePresence}
alias Soundboard.Discord.Voice
@interrupt_watchdog_ms 35
@interrupt_watchdog_max_attempts 20
defmodule State do
@moduledoc """
The state of the audio player.
"""
defstruct [
:voice_channel,
:current_playback,
:pending_request,
:interrupting,
:interrupt_watchdog_ref,
:interrupt_watchdog_attempt,
:idle_timeout_ref
]
@type t :: %__MODULE__{
voice_channel: {String.t(), String.t()} | nil,
current_playback: map() | nil,
pending_request: map() | nil,
interrupting: boolean() | nil,
interrupt_watchdog_ref: reference() | nil,
interrupt_watchdog_attempt: non_neg_integer() | nil,
idle_timeout_ref: {reference(), reference()} | nil
}
end
def start_link(_opts) do
GenServer.start_link(__MODULE__, %State{}, name: __MODULE__)
end
def play_sound(sound_name, actor) do
GenServer.cast(__MODULE__, {:play_sound, sound_name, actor})
end
def stop_sound do
GenServer.cast(__MODULE__, :stop_sound)
end
def set_voice_channel(guild_id, channel_id) do
GenServer.cast(__MODULE__, {:set_voice_channel, guild_id, channel_id})
end
def last_user_left(guild_id) do
GenServer.cast(__MODULE__, {:last_user_left, guild_id})
end
def user_joined_channel(guild_id) do
GenServer.cast(__MODULE__, {:user_joined_channel, guild_id})
end
def playback_finished(guild_id) do
GenServer.cast(__MODULE__, {:playback_finished, guild_id})
end
def current_voice_channel do
{:ok, GenServer.call(__MODULE__, :get_voice_channel)}
rescue
error -> {:error, {:voice_channel_unavailable, Exception.message(error)}}
catch
:exit, reason -> {:error, {:voice_channel_unavailable, reason}}
end
@doc """
Removes any cached metadata for the given `sound_name` so future plays use fresh data.
"""
def invalidate_cache(sound_name), do: SoundLibrary.invalidate_cache(sound_name)
@impl true
def init(state) do
SoundLibrary.ensure_cache()
schedule_voice_check()
{:ok,
%{
state
| current_playback: nil,
pending_request: nil,
interrupting: false,
interrupt_watchdog_ref: nil,
interrupt_watchdog_attempt: 0,
idle_timeout_ref: nil
}}
end
@impl true
def handle_cast({:set_voice_channel, guild_id, channel_id}, state) do
next_state =
case VoiceSession.normalize_channel(guild_id, channel_id) do
nil ->
state
|> PlaybackQueue.clear_all()
|> cancel_idle_timeout()
|> Map.put(:voice_channel, nil)
voice_channel ->
new_state =
state
|> cancel_idle_timeout()
|> Map.put(:voice_channel, voice_channel)
if AutoJoinPolicy.mode() == :play, do: schedule_idle_timeout(new_state), else: new_state
end
{:noreply, next_state}
end
def handle_cast(:stop_sound, %{voice_channel: {guild_id, _channel_id}} = state) do
Voice.stop(guild_id)
Notifier.sound_played("All sounds stopped", "System")
{:noreply, PlaybackQueue.clear_all(state)}
end
def handle_cast(:stop_sound, state) do
Notifier.error("Bot is not connected to a voice channel")
{:noreply, state}
end
def handle_cast({:playback_finished, guild_id}, state) do
{:noreply, PlaybackQueue.handle_playback_finished(state, guild_id)}
end
def handle_cast({:play_sound, sound_name, actor}, %{voice_channel: nil} = state) do
if AutoJoinPolicy.mode() == :play do
case try_auto_join(actor) do
{:ok, {guild_id, channel_id}} ->
new_state =
state
|> Map.put(:voice_channel, {guild_id, channel_id})
|> schedule_idle_timeout()
do_play_sound(sound_name, actor, new_state)
:not_found ->
Notifier.error("Bot is not connected to a voice channel. Use !join in Discord first.")
{:noreply, state}
end
else
Notifier.error("Bot is not connected to a voice channel. Use !join in Discord first.")
{:noreply, state}
end
end
def handle_cast({:play_sound, sound_name, actor}, state) do
do_play_sound(sound_name, actor, state)
end
def handle_cast({:last_user_left, guild_id}, %{voice_channel: {guild_id, _}} = state) do
case AutoJoinPolicy.mode() do
mode when mode in [:presence, :play] ->
Logger.info("Last user left (#{mode} mode); leaving guild #{guild_id}")
safely_leave(guild_id)
new_state =
state
|> cancel_idle_timeout()
|> PlaybackQueue.clear_all()
|> Map.put(:voice_channel, nil)
{:noreply, new_state}
false ->
Logger.info("Last user left (false mode); starting idle timer")
{:noreply, reset_idle_timeout(state)}
end
end
def handle_cast({:last_user_left, _guild_id}, state), do: {:noreply, state}
def handle_cast({:user_joined_channel, _guild_id}, state) do
{:noreply, cancel_idle_timeout(state)}
end
@impl true
def handle_call(:get_voice_channel, _from, state) do
{:reply, state.voice_channel, state}
end
@impl true
def handle_info(
{:idle_timeout, token},
%{idle_timeout_ref: {_ref, token}, voice_channel: {guild_id, _}} = state
) do
Logger.info("Voice idle timeout in guild #{guild_id}; leaving channel")
safely_leave(guild_id)
new_state =
%{state | idle_timeout_ref: nil}
|> PlaybackQueue.clear_all()
|> Map.put(:voice_channel, nil)
{:noreply, new_state}
end
def handle_info({:idle_timeout, _stale_token}, state), do: {:noreply, state}
@impl true
def handle_info(:check_voice_connection, state) do
schedule_voice_check()
{:noreply, VoiceSession.maintain_connection(state)}
end
@impl true
def handle_info({ref, result}, %{current_playback: %{task_ref: ref}} = state) do
Process.demonitor(ref, [:flush])
{:noreply, PlaybackQueue.handle_task_result(state, result)}
end
@impl true
def handle_info(
{:DOWN, ref, :process, _pid, reason},
%{current_playback: %{task_ref: ref}} = state
) do
{:noreply, PlaybackQueue.handle_task_down(state, reason)}
end
@impl true
def handle_info({:interrupt_watchdog, guild_id, attempt}, state) do
{:noreply,
PlaybackQueue.handle_interrupt_watchdog(
state,
guild_id,
attempt,
@interrupt_watchdog_max_attempts,
@interrupt_watchdog_ms
)}
end
@impl true
def handle_info(_, state), do: {:noreply, state}
defp do_play_sound(sound_name, actor, %{voice_channel: voice_channel} = state) do
case PlaybackQueue.build_request(voice_channel, sound_name, actor) do
{:ok, request} ->
new_state =
if AutoJoinPolicy.mode() == :play, do: reset_idle_timeout(state), else: state
{:noreply, PlaybackQueue.enqueue(new_state, request, @interrupt_watchdog_ms)}
{:error, reason} ->
Notifier.error(reason)
{:noreply, state}
end
end
defp try_auto_join(actor) do
case actor_discord_id(actor) do
nil -> :not_found
discord_id -> find_and_join_voice(discord_id)
end
end
defp find_and_join_voice(discord_id) do
case VoicePresence.find_user_voice_channel(discord_id) do
{:ok, {guild_id, channel_id}} ->
Logger.info(
"Auto-joining channel #{channel_id} in guild #{guild_id} for user #{discord_id}"
)
Voice.join_channel(guild_id, channel_id)
{:ok, {guild_id, channel_id}}
:not_found ->
Logger.info("User #{discord_id} not in a voice channel; skipping auto-join")
:not_found
end
rescue
error ->
Logger.warning("Auto-join failed: #{inspect(error)}")
:not_found
end
defp safely_leave(guild_id) do
Voice.leave_channel(guild_id)
rescue
error -> Logger.warning("Voice leave failed: #{inspect(error)}")
end
defp actor_discord_id(%User{discord_id: id}) when is_binary(id) and id != "", do: id
defp actor_discord_id(%{discord_id: id}) when is_binary(id) and id != "", do: id
defp actor_discord_id(_), do: nil
defp schedule_idle_timeout(state) do
case IdleTimeoutPolicy.timeout_ms() do
nil ->
state
ms ->
token = make_ref()
ref = Process.send_after(self(), {:idle_timeout, token}, ms)
%{state | idle_timeout_ref: {ref, token}}
end
end
defp cancel_idle_timeout(%{idle_timeout_ref: nil} = state), do: state
defp cancel_idle_timeout(%{idle_timeout_ref: {ref, _token}} = state) do
Process.cancel_timer(ref)
%{state | idle_timeout_ref: nil}
end
defp reset_idle_timeout(state) do
state |> cancel_idle_timeout() |> schedule_idle_timeout()
end
defp schedule_voice_check do
if Application.get_env(:soundboard, __MODULE__, [])[:voice_maintenance_enabled] != false do
Process.send_after(self(), :check_voice_connection, 30_000)
end
end
end
================================================
FILE: lib/soundboard/discord/bot_identity.ex
================================================
defmodule Soundboard.Discord.BotIdentity do
@moduledoc false
alias EDA.API.User
alias EDA.Cache
def fetch do
case Cache.me() do
nil -> fetch_from_api()
user -> {:ok, normalize_user(user)}
end
end
defp fetch_from_api do
case User.me() do
{:ok, user} ->
Cache.put_me(user)
{:ok, normalize_user(user)}
other ->
other
end
end
defp normalize_user(%{id: id}), do: %{id: id}
defp normalize_user(%{"id" => id}), do: %{id: id}
defp normalize_user(_), do: %{}
end
================================================
FILE: lib/soundboard/discord/consumer.ex
================================================
defmodule Soundboard.Discord.Consumer do
@moduledoc false
@behaviour EDA.Consumer
alias Soundboard.Discord.Handler
@impl true
def handle_event({event_name, payload}) do
event = {event_name, payload, nil}
Handler.dispatch_event(event)
end
end
================================================
FILE: lib/soundboard/discord/guild_cache.ex
================================================
defmodule Soundboard.Discord.GuildCache do
@moduledoc false
alias EDA.Cache
def all do
Cache.guilds()
|> Enum.map(&normalize_guild/1)
end
def get(guild_id) do
case Cache.get_guild(to_id(guild_id)) do
nil -> :error
guild -> {:ok, normalize_guild(guild)}
end
end
def get!(guild_id) do
case get(guild_id) do
{:ok, guild} -> guild
_ -> raise "guild #{guild_id} not found in cache"
end
end
defp normalize_guild(guild) do
guild_id = map_get(guild, "id")
channels = Cache.channels_for_guild(guild_id)
voice_states = Cache.voice_states(guild_id)
%{
id: guild_id,
name: map_get(guild, "name"),
channels: normalize_channels(channels, guild_id),
voice_states: Enum.map(voice_states, &normalize_voice_state(&1, guild_id))
}
end
defp normalize_channels(channels, guild_id) do
Enum.reduce(channels, %{}, fn channel, acc ->
channel_id = map_get(channel, "id")
Map.put(acc, channel_id, %{
id: channel_id,
guild_id: guild_id,
name: map_get(channel, "name")
})
end)
end
defp normalize_voice_state(voice_state, guild_id) do
%{
guild_id: guild_id,
channel_id: map_get(voice_state, "channel_id"),
user_id: map_get(voice_state, "user_id"),
session_id: map_get(voice_state, "session_id")
}
end
defp map_get(map, key) when is_map(map) do
case map do
%{^key => value} ->
value
_ ->
atom_key = String.to_atom(key)
Map.get(map, atom_key)
end
end
defp to_id(value) when is_integer(value), do: Integer.to_string(value)
defp to_id(value), do: to_string(value)
end
================================================
FILE: lib/soundboard/discord/handler/auto_join_policy.ex
================================================
defmodule Soundboard.Discord.Handler.AutoJoinPolicy do
@moduledoc false
@type mode :: :presence | :play | false
@spec mode() :: mode()
def mode do
case Application.get_env(:soundboard, :env) do
:test -> :play
_ -> parse_mode(System.get_env("AUTO_JOIN"))
end
end
defp parse_mode(nil), do: :play
defp parse_mode(value) do
case value |> String.trim() |> String.downcase() do
v when v in ["presence", "true", "1", "yes"] -> :presence
"play" -> :play
_ -> false
end
end
end
================================================
FILE: lib/soundboard/discord/handler/command_handler.ex
================================================
defmodule Soundboard.Discord.Handler.CommandHandler do
@moduledoc false
alias Soundboard.Discord.Handler.VoiceRuntime
alias Soundboard.Discord.Message
alias Soundboard.PublicURL
def handle_message(%{content: "!join"} = msg) do
case VoiceRuntime.user_voice_channel(msg.guild_id, msg.author.id) do
nil ->
Message.create(msg.channel_id, "You need to be in a voice channel!")
channel_id ->
VoiceRuntime.join_voice_channel(msg.guild_id, channel_id)
Message.create(msg.channel_id, joined_message())
end
end
def handle_message(%{content: "!leave", guild_id: guild_id, channel_id: channel_id})
when not is_nil(guild_id) do
VoiceRuntime.leave_voice_channel(guild_id)
Message.create(channel_id, "Left the voice channel!")
end
def handle_message(_msg), do: :ignore
defp joined_message do
url = PublicURL.current()
"""
Joined your voice channel!
Access the soundboard here: #{url}
"""
end
end
================================================
FILE: lib/soundboard/discord/handler/idle_timeout_policy.ex
================================================
defmodule Soundboard.Discord.Handler.IdleTimeoutPolicy do
@moduledoc false
require Logger
@default_seconds 600
@spec timeout_ms() :: pos_integer() | nil
def timeout_ms do
case raw_seconds() do
n when n <= 0 -> nil
n -> n * 1_000
end
end
defp raw_seconds do
case System.get_env("VOICE_IDLE_TIMEOUT_SECONDS") do
nil ->
@default_seconds
raw ->
case raw |> String.trim() |> Integer.parse() do
{n, ""} ->
n
_ ->
Logger.warning("Invalid VOICE_IDLE_TIMEOUT_SECONDS=#{inspect(raw)}; using default")
@default_seconds
end
end
end
end
================================================
FILE: lib/soundboard/discord/handler/sound_effects.ex
================================================
defmodule Soundboard.Discord.Handler.SoundEffects do
@moduledoc false
require Logger
alias Soundboard.{AudioPlayer, Sounds}
alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceRuntime}
def handle_join(user_id, previous_state, guild_id, channel_id) do
is_join_event =
case previous_state do
nil -> true
{nil, _} -> true
{prev_channel, _} -> prev_channel != channel_id
end
Logger.info(
"Join sound check - User: #{user_id}, Previous: #{inspect(previous_state)}, New channel: #{channel_id}, Is join: #{is_join_event}"
)
if is_join_event do
play_join_sound(user_id, guild_id, channel_id)
else
:noop
end
end
def handle_leave(user_id) do
case Sounds.get_user_leave_sound_by_discord_id(user_id) do
leave_sound when is_binary(leave_sound) ->
Logger.info("Playing leave sound: #{leave_sound}")
AudioPlayer.play_sound(leave_sound, "System")
_ ->
:noop
end
end
defp play_join_sound(user_id, guild_id, channel_id) do
join_sound = Sounds.get_user_join_sound_by_discord_id(user_id)
Logger.info("Join sound query result for user #{user_id}: #{inspect(join_sound)}")
case join_sound do
join_sound when is_binary(join_sound) ->
Logger.info("Playing join sound immediately: #{join_sound}")
maybe_join_for_sound(guild_id, channel_id)
AudioPlayer.play_sound(join_sound, "System")
_ ->
Logger.info("No join sound found for user #{user_id}")
:noop
end
end
defp maybe_join_for_sound(guild_id, channel_id) do
if AutoJoinPolicy.mode() == :play && VoiceRuntime.get_current_voice_channel() == nil do
Logger.info("Auto-joining #{guild_id}/#{channel_id} to play join sound")
VoiceRuntime.join_voice_channel(guild_id, channel_id)
end
end
end
================================================
FILE: lib/soundboard/discord/handler/voice_commands.ex
================================================
defmodule Soundboard.Discord.Handler.VoiceCommands do
@moduledoc false
require Logger
alias Soundboard.AudioPlayer
alias Soundboard.Discord.{BotIdentity, Voice}
def join_voice_channel(guild_id, channel_id) do
execute(
connected_to_discord?(),
"Skipping join_voice_channel - not connected to Discord",
fn ->
Logger.info("Bot joining voice channel #{channel_id} in guild #{guild_id}")
run("join voice channel", fn -> Voice.join_channel(guild_id, channel_id) end)
end,
fn -> AudioPlayer.set_voice_channel(guild_id, channel_id) end,
fn error_msg -> Logger.error("Error joining voice channel: #{error_msg}") end
)
end
def leave_voice_channel(guild_id) do
execute(
connected_to_discord?(),
"Skipping leave_voice_channel - not connected to Discord",
fn ->
Logger.info("Bot leaving voice channel in guild #{guild_id}")
run("leave voice channel", fn -> Voice.leave_channel(guild_id) end)
end,
fn -> AudioPlayer.set_voice_channel(nil, nil) end,
fn error_msg -> Logger.error("Error leaving voice channel: #{error_msg}") end
)
end
def connected_to_discord? do
ready = :persistent_term.get(:soundboard_bot_ready, false)
if ready do
try do
case BotIdentity.fetch() do
{:ok, _} ->
Logger.debug("Discord connection check: Connected and ready")
true
error ->
Logger.debug("Discord connection check failed: #{inspect(error)}")
false
end
rescue
error ->
Logger.debug("Discord connection check error: #{inspect(error)}")
false
end
else
Logger.debug("Discord connection check: Bot not ready (READY event not received)")
false
end
end
defp execute(true, _skip_message, command_fun, success_fun, error_fun) do
case command_fun.() do
:ok -> success_fun.()
{:error, error_msg} -> error_fun.(error_msg)
end
end
defp execute(false, skip_message, _command_fun, _success_fun, _error_fun) do
Logger.warning(skip_message)
end
defp run(action, command) do
case safely_run(command) do
:ok ->
:ok
{:error, error_msg} ->
if rate_limited?(error_msg) do
Logger.warning("Rate limited while trying to #{action}, retrying in 5 seconds...")
Process.sleep(5000)
safely_run(command)
else
{:error, error_msg}
end
end
end
defp safely_run(command) do
case command.() do
:ok -> :ok
other -> {:error, inspect(other)}
end
rescue
error -> {:error, Exception.message(error)}
end
defp rate_limited?(error_msg) do
is_binary(error_msg) and String.contains?(String.downcase(error_msg), "rate limit")
end
end
================================================
FILE: lib/soundboard/discord/handler/voice_presence.ex
================================================
defmodule Soundboard.Discord.Handler.VoicePresence do
@moduledoc false
require Logger
alias Soundboard.AudioPlayer
alias Soundboard.Discord.{BotIdentity, GuildCache}
def current_voice_channel do
with {:ok, bot_id} <- bot_id() do
case find_bot_voice_channel(bot_id) do
nil -> :not_found
channel -> {:ok, channel}
end
end
end
def user_voice_channel(guild_id, user_id) do
case GuildCache.get(guild_id) do
{:ok, guild} -> find_user_voice_channel(guild, user_id)
:error -> {:error, {:guild_unavailable, guild_id}}
end
end
def bot_user?(user_id) do
case bot_id() do
{:ok, bot_id} -> to_string(bot_id) == to_string(user_id)
_ -> false
end
end
def bot_id do
case BotIdentity.fetch() do
{:ok, %{id: id}} when not is_nil(id) -> {:ok, id}
{:ok, _} -> {:error, :bot_identity_missing}
{:error, reason} -> {:error, {:bot_identity_unavailable, reason}}
other -> {:error, {:bot_identity_unavailable, other}}
end
end
def cached_guilds do
{:ok, GuildCache.all() |> Enum.to_list()}
rescue
error -> {:error, {:guild_cache_unavailable, Exception.message(error)}}
end
def find_user_voice_channel(discord_id) do
case cached_guilds() do
{:ok, guilds} ->
Enum.find_value(guilds, :not_found, &find_in_guild(&1, discord_id))
{:error, reason} ->
Logger.debug("Guild cache unavailable for user voice channel lookup: #{inspect(reason)}")
:not_found
end
end
defp find_in_guild(guild, discord_id) do
target = to_string(discord_id)
case Enum.find(guild.voice_states, fn vs -> to_string(vs.user_id) == target end) do
%{channel_id: channel_id} when not is_nil(channel_id) -> {:ok, {guild.id, channel_id}}
_ -> nil
end
end
def users_in_channel(guild_id, channel_id) do
cond do
not valid_discord_id?(guild_id) ->
{:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}
is_nil(channel_id) ->
{:error, {:invalid_voice_target, %{guild_id: guild_id, channel_id: channel_id}}}
true ->
count_users_in_channel(guild_id, channel_id)
end
end
defp count_users_in_channel(guild_id, channel_id) do
case GuildCache.get(guild_id) do
{:ok, guild} ->
bot_id = bot_id_value()
voice_states = List.wrap(guild.voice_states)
users_in_channel =
voice_states
|> Enum.count(fn vs -> vs.channel_id == channel_id && vs.user_id != bot_id end)
log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states)
{:ok, users_in_channel}
:error ->
{:error, {:guild_unavailable, guild_id}}
end
end
defp bot_id_value do
case bot_id() do
{:ok, id} -> id
_ -> nil
end
end
defp log_voice_state_snapshot(channel_id, users_in_channel, bot_id, voice_states) do
Logger.info("""
Voice state check:
Channel ID: #{channel_id}
Users in channel: #{users_in_channel} (excluding bot)
Bot ID: #{bot_id}
Voice states: #{inspect(voice_states)}
""")
end
defp find_bot_voice_channel(bot_id) do
case cached_guilds() do
{:ok, []} ->
fallback_voice_channel()
{:ok, guilds} ->
find_voice_channel_in_guilds(guilds, bot_id) || fallback_voice_channel()
{:error, reason} ->
Logger.debug("Guild cache unavailable for bot voice channel lookup: #{inspect(reason)}")
fallback_voice_channel()
end
end
defp find_user_voice_channel(guild, user_id) do
case Enum.find(guild.voice_states, fn vs -> vs.user_id == user_id end) do
nil -> :not_found
voice_state -> {:ok, voice_state.channel_id}
end
end
defp find_voice_channel_in_guilds(guilds, bot_id) do
Enum.find_value(guilds, &voice_channel_for_guild(&1, bot_id))
end
defp voice_channel_for_guild(guild, bot_id) do
guild.voice_states
|> List.wrap()
|> Enum.find_value(fn
%{user_id: ^bot_id, channel_id: channel_id} when not is_nil(channel_id) ->
{guild.id, channel_id}
_ ->
nil
end)
end
defp fallback_voice_channel do
case AudioPlayer.current_voice_channel() do
{:ok, {gid, cid}} when not is_nil(gid) and not is_nil(cid) ->
{gid, cid}
{:ok, _} ->
nil
{:error, reason} ->
Logger.debug("Audio player voice channel unavailable: #{inspect(reason)}")
nil
end
end
defp valid_discord_id?(value), do: is_integer(value) or (is_binary(value) and value != "")
end
================================================
FILE: lib/soundboard/discord/handler/voice_runtime.ex
================================================
defmodule Soundboard.Discord.Handler.VoiceRuntime do
@moduledoc false
require Logger
alias Soundboard.AudioPlayer
alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoiceCommands, VoicePresence}
alias Soundboard.Discord.Voice
@type runtime_action :: {:schedule_recheck_alone, String.t(), String.t(), non_neg_integer()}
def bootstrap do
Logger.info("Starting DiscordHandler...")
if AutoJoinPolicy.mode() == :presence, do: start_guild_check_task()
:ok
end
def join_voice_channel(guild_id, channel_id),
do: VoiceCommands.join_voice_channel(guild_id, channel_id)
def leave_voice_channel(guild_id), do: VoiceCommands.leave_voice_channel(guild_id)
@spec handle_connect(map()) :: [runtime_action()]
def handle_connect(payload) do
case AutoJoinPolicy.mode() do
:presence -> handle_auto_join_leave(payload)
false -> handle_user_rejoin_cancel(payload)
:play -> []
end
end
@spec handle_disconnect(map()) :: [runtime_action()]
def handle_disconnect(payload) do
if bot_user?(payload.user_id) do
[]
else
handle_bot_alone_check(payload.guild_id)
end
end
@spec recheck_alone(String.t(), String.t()) :: [runtime_action()]
def recheck_alone(guild_id, channel_id) do
case current_voice_channel_status() do
{:ok, {^guild_id, ^channel_id}} -> handle_recheck_alone(guild_id, channel_id)
_ -> Logger.debug("Recheck skipped; voice target changed")
end
[]
end
def get_current_voice_channel do
case current_voice_channel_status() do
{:ok, channel} -> channel
_ -> nil
end
end
def user_voice_channel(guild_id, user_id) do
case VoicePresence.user_voice_channel(guild_id, user_id) do
{:ok, channel_id} -> channel_id
_ -> nil
end
end
def bot_user?(user_id), do: VoicePresence.bot_user?(user_id)
defp start_guild_check_task do
Task.start(fn ->
Logger.info("Starting voice channel check task...")
Process.sleep(5000)
check_guilds()
end)
end
defp check_guilds do
case VoicePresence.cached_guilds() do
{:ok, []} ->
Logger.warning("No guilds found in cache. Discord may not be ready.")
{:ok, guilds} ->
process_guilds(guilds)
{:error, reason} ->
Logger.warning("Guild cache unavailable during bootstrap: #{inspect(reason)}")
end
end
defp process_guilds(guilds) do
Logger.info("Checking #{length(guilds)} guilds for active voice channels")
Enum.each(guilds, &check_and_join_voice/1)
end
defp check_and_join_voice(guild) do
voice_states = guild.voice_states
bot_id = current_bot_id()
case Enum.find(voice_states, fn vs -> vs.user_id != bot_id && vs.channel_id != nil end) do
%{channel_id: channel_id} ->
Logger.info("Auto-joining guild #{guild.id} channel #{channel_id} during bootstrap")
Voice.join_channel(guild.id, channel_id)
AudioPlayer.set_voice_channel(guild.id, channel_id)
_ ->
:ok
end
end
defp handle_recheck_alone(guild_id, channel_id) do
case VoicePresence.users_in_channel(guild_id, channel_id) do
{:ok, users} ->
Logger.info("Recheck alone: channel #{channel_id} now has #{users} non-bot users")
maybe_act_if_bot_alone(guild_id, channel_id, users)
{:error, reason} ->
Logger.warning("Recheck skipped because voice state was unavailable: #{inspect(reason)}")
end
end
defp maybe_act_if_bot_alone(guild_id, _channel_id, 0) do
Logger.info("Recheck confirms bot is alone; leaving channel")
bot_alone_action(guild_id)
end
defp maybe_act_if_bot_alone(_guild_id, _channel_id, _users), do: :ok
defp handle_bot_alone_check(_guild_id) do
case current_voice_channel_status() do
{:ok, {guild_id, channel_id}} -> check_and_maybe_act(guild_id, channel_id)
_ -> []
end
end
defp check_and_maybe_act(guild_id, channel_id) do
case VoicePresence.users_in_channel(guild_id, channel_id) do
{:ok, 0} ->
Logger.info("No non-bot users remaining in channel, acting on bot alone")
bot_alone_action(guild_id)
[]
{:ok, users} ->
Logger.info("Non-bot users detected (#{users}); scheduling recheck in 1.5s")
[schedule_recheck(guild_id, channel_id)]
{:error, reason} ->
Logger.warning(
"Skipping leave check because voice state was unavailable: #{inspect(reason)}"
)
[]
end
end
defp bot_alone_action(guild_id) do
case AutoJoinPolicy.mode() do
:presence -> leave_voice_channel(guild_id)
_ -> AudioPlayer.last_user_left(guild_id)
end
end
defp handle_auto_join_leave(payload) do
if bot_user?(payload.user_id) do
Logger.debug("Ignoring bot's own voice state update in auto-join logic")
[]
else
process_user_voice_update(payload)
end
end
defp handle_user_rejoin_cancel(payload) do
if bot_user?(payload.user_id) do
[]
else
case current_voice_channel_status() do
{:ok, {guild_id, channel_id}}
when guild_id == payload.guild_id and channel_id == payload.channel_id ->
Logger.debug("User rejoined bot's channel (false mode); cancelling idle timer")
AudioPlayer.user_joined_channel(guild_id)
[]
_ ->
[]
end
end
end
defp process_user_voice_update(payload) do
case current_voice_channel_status() do
:not_found when payload.channel_id != nil ->
handle_bot_not_in_voice(payload)
{:ok, {guild_id, current_channel_id}} when current_channel_id != payload.channel_id ->
handle_bot_in_different_channel(guild_id, current_channel_id)
_ ->
Logger.debug("No action needed for voice state update")
[]
end
end
defp handle_bot_not_in_voice(payload) do
case VoicePresence.users_in_channel(payload.guild_id, payload.channel_id) do
{:ok, users_in_channel} ->
Logger.info("Found #{users_in_channel} users in channel #{payload.channel_id}")
maybe_join_channel_for_payload(payload, users_in_channel)
[]
{:error, reason} ->
Logger.warning(
"Skipping auto-join because voice state was unavailable: #{inspect(reason)}"
)
[]
end
end
defp handle_bot_in_different_channel(guild_id, current_channel_id) do
case VoicePresence.users_in_channel(guild_id, current_channel_id) do
{:ok, users} ->
Logger.info("Current channel #{current_channel_id} has #{users} users")
handle_current_channel_users(guild_id, current_channel_id, users)
{:error, reason} ->
Logger.warning(
"Skipping channel switch handling because voice state was unavailable: #{inspect(reason)}"
)
[]
end
end
defp maybe_join_channel_for_payload(_payload, users_in_channel) when users_in_channel <= 0,
do: :ok
defp maybe_join_channel_for_payload(payload, users_in_channel) do
if Voice.ready?(payload.guild_id) do
Logger.debug("Bot already connected to voice in guild #{payload.guild_id}, skipping join")
else
Logger.info("Joining channel #{payload.channel_id} with #{users_in_channel} users")
join_voice_channel(payload.guild_id, payload.channel_id)
end
end
defp handle_current_channel_users(guild_id, current_channel_id, 0) do
Logger.info("Bot is alone in channel #{current_channel_id}, leaving")
leave_voice_channel(guild_id)
[]
end
defp handle_current_channel_users(guild_id, current_channel_id, _users) do
[schedule_recheck(guild_id, current_channel_id)]
end
defp schedule_recheck(guild_id, channel_id),
do: {:schedule_recheck_alone, guild_id, channel_id, 1_500}
defp current_voice_channel_status do
case VoicePresence.current_voice_channel() do
{:ok, channel} ->
{:ok, channel}
:not_found ->
:not_found
{:error, reason} ->
Logger.debug("Current voice channel unavailable: #{inspect(reason)}")
:not_found
end
end
defp current_bot_id do
case VoicePresence.bot_id() do
{:ok, bot_id} -> bot_id
_ -> nil
end
end
end
================================================
FILE: lib/soundboard/discord/handler.ex
================================================
defmodule Soundboard.Discord.Handler do
@moduledoc """
Handles the Discord events.
"""
use GenServer
require Logger
alias Soundboard.Discord.Handler.{CommandHandler, SoundEffects, VoiceRuntime}
defmodule State do
@moduledoc """
Handles the state of the Discord handler.
"""
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(_) do
{:ok, %{voice_states: %{}}}
end
def get_state(user_id) do
GenServer.call(__MODULE__, {:get_state, user_id})
catch
:exit, _ -> nil
end
def update_state(user_id, channel_id, session_id) do
GenServer.cast(__MODULE__, {:update_state, user_id, channel_id, session_id})
catch
:exit, _ -> :error
end
def handle_call({:get_state, user_id}, _from, state) do
{:reply, Map.get(state.voice_states, user_id), state}
end
def handle_cast({:update_state, user_id, channel_id, session_id}, state) do
{:noreply,
%{state | voice_states: Map.put(state.voice_states, user_id, {channel_id, session_id})}}
end
end
def init do
VoiceRuntime.bootstrap()
end
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def dispatch_event(event) do
case Process.whereis(__MODULE__) do
nil ->
Logger.warning("DiscordHandler is not running; dropping event #{inspect(elem(event, 0))}")
:error
_pid ->
GenServer.cast(__MODULE__, {:eda_event, event})
:ok
end
end
@impl GenServer
def init([]) do
init()
{:ok, nil}
end
def handle_event({:VOICE_STATE_UPDATE, %{channel_id: nil} = payload, _ws_state}) do
Logger.info("User #{payload.user_id} disconnected from voice")
State.update_state(payload.user_id, nil, payload.session_id)
if VoiceRuntime.bot_user?(payload.user_id) do
Logger.debug("Skipping leave sound lookup for bot user #{payload.user_id}")
else
SoundEffects.handle_leave(payload.user_id)
end
VoiceRuntime.handle_disconnect(payload)
end
def handle_event({:VOICE_STATE_UPDATE, payload, _ws_state}) do
Logger.info("Voice state update received: #{inspect(payload)}")
if VoiceRuntime.bot_user?(payload.user_id) do
Logger.info(
"BOT VOICE STATE UPDATE - Bot joined channel #{payload.channel_id} in guild #{payload.guild_id}"
)
end
previous_state = State.get_state(payload.user_id)
State.update_state(payload.user_id, payload.channel_id, payload.session_id)
runtime_actions = VoiceRuntime.handle_connect(payload)
if VoiceRuntime.bot_user?(payload.user_id) do
Logger.debug("Skipping join sound lookup for bot user #{payload.user_id}")
else
SoundEffects.handle_join(
payload.user_id,
previous_state,
payload.guild_id,
payload.channel_id
)
end
runtime_actions
end
def handle_event({:READY, _payload, _ws_state}) do
Logger.info("Bot is READY - gateway connection established")
:persistent_term.put(:soundboard_bot_ready, true)
[]
end
def handle_event({:VOICE_READY, payload, _ws_state}) do
Logger.info("""
Voice Ready Event:
Guild ID: #{payload.guild_id}
Channel ID: #{payload.channel_id}
""")
[]
end
def handle_event({:VOICE_PLAYBACK_FINISHED, payload, _ws_state}) do
Soundboard.AudioPlayer.playback_finished(payload.guild_id)
[]
end
def handle_event({:VOICE_SERVER_UPDATE, _payload, _ws_state}), do: []
def handle_event({:VOICE_CHANNEL_STATUS_UPDATE, _payload, _ws_state}), do: []
def handle_event({:MESSAGE_CREATE, msg, _ws_state}) do
CommandHandler.handle_message(msg)
[]
end
def handle_event(_event), do: []
@impl true
def handle_cast({:eda_event, event}, state) do
event
|> handle_event()
|> apply_runtime_actions()
{:noreply, state}
end
@impl true
def handle_info({:event, {event_name, payload, ws_state}}, state) do
{event_name, payload, ws_state}
|> handle_event()
|> apply_runtime_actions()
{:noreply, state}
end
def handle_info({:recheck_alone, guild_id, channel_id}, state) do
guild_id
|> VoiceRuntime.recheck_alone(channel_id)
|> apply_runtime_actions()
{:noreply, state}
end
def handle_info(_msg, state), do: {:noreply, state}
def get_current_voice_channel do
VoiceRuntime.get_current_voice_channel()
end
defp apply_runtime_actions(actions) when is_list(actions) do
Enum.each(actions, &apply_runtime_action/1)
end
defp apply_runtime_actions(_actions), do: :ok
defp apply_runtime_action({:schedule_recheck_alone, guild_id, channel_id, delay_ms}) do
Process.send_after(self(), {:recheck_alone, guild_id, channel_id}, delay_ms)
end
defp apply_runtime_action(_action), do: :ok
end
================================================
FILE: lib/soundboard/discord/message.ex
================================================
defmodule Soundboard.Discord.Message do
@moduledoc false
alias EDA.API.Message, as: EDAMessage
def create(channel_id, payload) do
EDAMessage.create(to_id(channel_id), payload)
end
defp to_id(value) when is_integer(value), do: Integer.to_string(value)
defp to_id(value), do: to_string(value)
end
================================================
FILE: lib/soundboard/discord/role_checker.ex
================================================
defmodule Soundboard.Discord.RoleChecker do
@moduledoc false
require Logger
alias EDA.API.Member
@doc """
Check if the role-gated access feature is enabled.
Returns true only when both required_guild_id and required_role_ids are configured.
"""
def feature_enabled? do
guild_id = Application.get_env(:soundboard, :required_guild_id)
role_ids = Application.get_env(:soundboard, :required_role_ids, [])
not is_nil(guild_id) and Enum.any?(role_ids)
end
@doc """
Check if a user is authorized to access the application.
Returns true if:
- The feature is disabled, OR
- The user's member object contains at least one of the required roles
Returns false if:
- The feature is enabled and the API call fails, OR
- The user has none of the required roles, OR
- The API response shape is unexpected
"""
def authorized?(user_id) do
if feature_enabled?() do
check_member_roles(user_id)
else
true
end
end
defp check_member_roles(user_id) do
guild_id = Application.get_env(:soundboard, :required_guild_id)
guild_id
|> Member.get(user_id)
|> member_authorized?(user_id)
end
defp member_authorized?({:ok, %{"roles" => roles}}, user_id) when is_list(roles) do
required_role_ids = Application.get_env(:soundboard, :required_role_ids, [])
authorized = Enum.any?(roles, &Enum.member?(required_role_ids, &1))
unless authorized do
Logger.info("Discord user #{user_id} has no matching required roles")
end
authorized
end
defp member_authorized?({:ok, _member}, user_id) do
Logger.warning("Unexpected member response shape for Discord user #{user_id}")
false
end
defp member_authorized?({:error, reason}, user_id) do
Logger.error("Member API error for Discord user #{user_id}: #{inspect(reason)}")
false
end
end
================================================
FILE: lib/soundboard/discord/runtime_capability.ex
================================================
defmodule Soundboard.Discord.RuntimeCapability do
@moduledoc false
require Logger
alias EDA.Voice.Dave.Native
def discord_handler_enabled? do
Application.get_env(:soundboard, :env) != :test and voice_runtime_available?()
end
def voice_runtime_available? do
match?(:ok, voice_runtime_status())
end
def voice_runtime_status do
cond do
Application.get_env(:soundboard, :env) == :test ->
:ok
not Application.get_env(:eda, :dave, false) ->
:ok
Native.available?() ->
:ok
true ->
{:degraded, :dave_unavailable}
end
end
def log_degraded_mode do
case voice_runtime_status() do
{:degraded, :dave_unavailable} ->
Logger.error("""
Discord voice runtime is disabled because EDA DAVE is enabled but the native library is unavailable.
The web app will continue to boot, but Discord voice features stay offline until DAVE is packaged correctly
or EDA_DAVE=false is configured.
""")
:ok
_ ->
:ok
end
end
end
================================================
FILE: lib/soundboard/discord/voice.ex
================================================
defmodule Soundboard.Discord.Voice do
@moduledoc false
require Logger
alias EDA.Voice, as: EDAVoice
@connected_error "Must be connected to voice channel to play audio."
@not_ready_error "Voice session is still negotiating encryption."
@already_playing_error "Audio already playing in voice channel."
def join_channel(guild_id, channel_id) do
voice_module().join(to_id(guild_id), to_id(channel_id))
end
def leave_channel(guild_id) do
voice_module().leave(to_id(guild_id))
end
def play(guild_id, input, type, opts \\ []) do
guild_id = to_id(guild_id)
case play_with_supported_arity(guild_id, input, type, opts) do
:ok -> :ok
{:error, :already_playing} -> {:error, @already_playing_error}
{:error, :not_connected} -> {:error, @connected_error}
{:error, :not_ready} -> {:error, @not_ready_error}
{:error, reason} -> {:error, inspect(reason)}
end
end
def stop(guild_id) do
voice_module().stop(to_id(guild_id))
end
def ready?(guild_id) do
voice_module().ready?(to_id(guild_id))
end
def channel_id(guild_id) do
voice_module().channel_id(to_id(guild_id))
end
def playing?(guild_id) do
voice_module().playing?(to_id(guild_id))
end
# Compatibility shape for existing RTP probe code.
def get_voice(guild_id) do
case voice_module().get_voice_state(to_id(guild_id)) do
{:ok, %{sequence: seq} = state} -> {:ok, %{rtp_sequence: seq, state: state}}
{:ok, state} -> {:ok, %{state: state}}
{:error, reason} -> {:error, reason}
other -> {:error, {:unexpected_voice_state, other}}
end
end
defp play_with_supported_arity(guild_id, input, type, opts) do
module = voice_module()
cond do
function_exported?(module, :play, 4) ->
:erlang.apply(module, :play, [guild_id, input, type, opts])
opts == [] ->
module.play(guild_id, input, type)
true ->
Logger.debug("EDA.Voice.play/4 unavailable; dropping playback opts #{inspect(opts)}")
module.play(guild_id, input, type)
end
end
defp voice_module do
Application.get_env(:soundboard, :eda_voice_module, EDAVoice)
end
defp to_id(nil), do: nil
defp to_id(value) when is_integer(value), do: Integer.to_string(value)
defp to_id(value), do: to_string(value)
end
================================================
FILE: lib/soundboard/favorites/favorite.ex
================================================
defmodule Soundboard.Favorites.Favorite do
@moduledoc """
The Favorite module.
"""
use Ecto.Schema
import Ecto.Changeset
alias Soundboard.Accounts.User
alias Soundboard.Sound
schema "favorites" do
belongs_to :user, User
belongs_to :sound, Sound
timestamps()
end
def changeset(favorite, attrs) do
favorite
|> cast(attrs, [:user_id, :sound_id])
|> validate_required([:user_id, :sound_id])
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:sound_id)
|> unique_constraint([:user_id, :sound_id])
end
end
================================================
FILE: lib/soundboard/favorites.ex
================================================
defmodule Soundboard.Favorites do
@moduledoc """
The Favorites module.
"""
import Ecto.Query
alias Soundboard.{Favorites.Favorite, Repo, Sound}
@type favorite_result :: {:ok, Favorite.t()} | {:error, Ecto.Changeset.t()}
@max_favorites 16
@spec list_favorites(integer()) :: [integer()]
def list_favorites(user_id) do
Favorite
|> where([f], f.user_id == ^user_id)
|> select([f], f.sound_id)
|> Repo.all()
end
@spec list_favorite_sounds_with_tags(integer()) :: [Sound.t()]
def list_favorite_sounds_with_tags(user_id) do
favorite_ids_query =
Favorite
|> where([f], f.user_id == ^user_id)
|> select([f], f.sound_id)
Sound.with_tags()
|> where([s], s.id in subquery(favorite_ids_query))
|> order_by([s], asc: fragment("lower(?)", s.filename))
|> Repo.all()
end
@spec toggle_favorite(integer(), integer()) :: favorite_result()
def toggle_favorite(user_id, sound_id) do
case Repo.get_by(Favorite, user_id: user_id, sound_id: sound_id) do
nil -> add_favorite(user_id, sound_id)
favorite -> Repo.delete(favorite)
end
end
@spec error_message(Ecto.Changeset.t()) :: String.t()
def error_message(%Ecto.Changeset{} = changeset) do
Enum.map_join(changeset.errors, ", ", fn
{:base, {msg, _}} -> msg
{:sound, {"does not exist", _}} -> "Sound does not exist"
{field, {msg, _}} -> "#{field} #{msg}"
end)
end
defp add_favorite(user_id, sound_id) do
case Repo.get(Sound, sound_id) do
nil ->
{:error,
Ecto.Changeset.add_error(Ecto.Changeset.change(%Favorite{}), :sound, "does not exist")}
_sound ->
# Check if user has reached max favorites
count = Repo.one(from f in Favorite, where: f.user_id == ^user_id, select: count())
if count >= @max_favorites do
{:error,
Ecto.Changeset.add_error(
Ecto.Changeset.change(%Favorite{}),
:base,
"You can only have #{@max_favorites} favorites"
)}
else
%Favorite{}
|> Favorite.changeset(%{user_id: user_id, sound_id: sound_id})
|> Repo.insert()
end
end
end
@spec favorite?(integer(), integer()) :: boolean()
def favorite?(user_id, sound_id) do
Repo.exists?(from f in Favorite, where: f.user_id == ^user_id and f.sound_id == ^sound_id)
end
@spec max_favorites() :: pos_integer()
def max_favorites, do: @max_favorites
end
================================================
FILE: lib/soundboard/public_url.ex
================================================
defmodule Soundboard.PublicURL do
@moduledoc """
Shared helper for the application's externally visible base URL.
Web and Discord-facing features use this so URL generation follows one
application-level contract instead of reaching into endpoint config details in
multiple places.
"""
def current, do: SoundboardWeb.Endpoint.url()
def from_uri_or_current(nil), do: current()
def from_uri_or_current(uri) do
case URI.parse(uri) do
%URI{scheme: scheme, host: host, port: port} when is_binary(scheme) and is_binary(host) ->
scheme <> "://" <> host <> port_suffix(scheme, port)
_ ->
current()
end
end
defp port_suffix("http", 80), do: ""
defp port_suffix("https", 443), do: ""
defp port_suffix(_scheme, nil), do: ""
defp port_suffix(_scheme, port), do: ":#{port}"
end
================================================
FILE: lib/soundboard/pubsub_topics.ex
================================================
defmodule Soundboard.PubSubTopics do
@moduledoc false
alias Phoenix.PubSub
@files_topic "soundboard.files"
@playback_topic "soundboard.playback"
@stats_topic "soundboard.stats"
def files_topic, do: @files_topic
def playback_topic, do: @playback_topic
def stats_topic, do: @stats_topic
def subscribe_files, do: PubSub.subscribe(Soundboard.PubSub, @files_topic)
def subscribe_playback, do: PubSub.subscribe(Soundboard.PubSub, @playback_topic)
def subscribe_stats, do: PubSub.subscribe(Soundboard.PubSub, @stats_topic)
def broadcast_files_updated do
PubSub.broadcast(Soundboard.PubSub, @files_topic, {:files_updated})
end
def broadcast_stats_updated do
PubSub.broadcast(Soundboard.PubSub, @stats_topic, {:stats_updated})
end
def broadcast_sound_played(sound_name, username) do
PubSub.broadcast(
Soundboard.PubSub,
@playback_topic,
{:sound_played, %{filename: sound_name, played_by: username}}
)
end
def broadcast_error(message) do
PubSub.broadcast(Soundboard.PubSub, @playback_topic, {:error, message})
end
end
================================================
FILE: lib/soundboard/release.ex
================================================
defmodule Soundboard.Release do
@moduledoc false
@app :soundboard
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn repo_ref ->
Ecto.Migrator.run(repo_ref, :up, all: true)
end)
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn repo_ref ->
Ecto.Migrator.run(repo_ref, :down, to: version)
end)
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end
================================================
FILE: lib/soundboard/repo.ex
================================================
defmodule Soundboard.Repo do
use Ecto.Repo,
otp_app: :soundboard,
adapter: Ecto.Adapters.SQLite3
end
================================================
FILE: lib/soundboard/sound.ex
================================================
defmodule Soundboard.Sound do
@moduledoc """
Sound schema.
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
@type t :: %__MODULE__{}
@spec changeset(t(), map()) :: Ecto.Changeset.t()
@spec with_tags(Ecto.Queryable.t()) :: Ecto.Query.t()
@spec by_tag(Ecto.Queryable.t(), String.t()) :: Ecto.Query.t()
schema "sounds" do
field :filename, :string
field :url, :string
field :source_type, :string, default: "local"
field :description, :string
field :volume, :float, default: 1.0
belongs_to :user, Soundboard.Accounts.User
has_many :user_sound_settings, Soundboard.UserSoundSetting
many_to_many :tags, Soundboard.Tag,
join_through: Soundboard.SoundTag,
on_replace: :delete,
unique: true
timestamps()
end
def changeset(sound, attrs) do
sound
|> cast(attrs, [
:filename,
:url,
:source_type,
:description,
:user_id,
:volume
])
|> validate_required([:user_id])
|> validate_source_type()
|> validate_volume()
|> unique_constraint(:filename, name: :sounds_filename_index)
|> put_tags(attrs)
end
def with_tags(query \\ __MODULE__) do
from s in query,
preload: [:tags]
end
def by_tag(query \\ __MODULE__, tag_name) do
from s in query,
join: t in assoc(s, :tags),
where: t.name == ^tag_name
end
defp validate_source_type(changeset) do
case get_field(changeset, :source_type) do
"local" -> validate_required(changeset, [:filename])
"url" -> validate_required(changeset, [:url])
_ -> add_error(changeset, :source_type, "must be either 'local' or 'url'")
end
end
defp put_tags(changeset, %{tags: tags}) when is_list(tags) do
put_assoc(changeset, :tags, tags)
end
defp put_tags(changeset, _), do: changeset
defp validate_volume(changeset) do
changeset
|> validate_number(:volume,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 1.5
)
|> case do
%{changes: %{volume: volume}} = cs when is_nil(volume) ->
put_change(cs, :volume, 1.0)
cs ->
cs
end
end
end
================================================
FILE: lib/soundboard/sound_tag.ex
================================================
defmodule Soundboard.SoundTag do
@moduledoc """
The SoundTag module.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "sound_tags" do
belongs_to :sound, Soundboard.Sound, primary_key: true
belongs_to :tag, Soundboard.Tag, primary_key: true
timestamps()
end
def changeset(sound_tag, attrs) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
sound_tag
|> cast(attrs, [:sound_id, :tag_id])
|> validate_required([:sound_id, :tag_id])
|> unique_constraint([:sound_id, :tag_id])
|> put_change(:inserted_at, now)
|> put_change(:updated_at, now)
end
end
================================================
FILE: lib/soundboard/sounds/management.ex
================================================
defmodule Soundboard.Sounds.Management do
@moduledoc """
Domain-level sound update/delete operations used by LiveViews.
Sound metadata edits are collaborative for signed-in users, while deletion
remains restricted to the original uploader. Per-user join/leave preferences
are stored separately so editors keep their own settings without taking over
sound ownership.
"""
alias Soundboard.{AudioPlayer, Repo, Sound, UploadsPath, Volume}
require Logger
def update_sound(%Sound{} = sound, user_id, params) do
Repo.transaction(fn ->
db_sound =
Repo.get!(Sound, sound.id)
|> Repo.preload(:user_sound_settings)
old_path = UploadsPath.file_path(db_sound.filename)
new_filename = params["filename"] <> Path.extname(db_sound.filename)
new_path = UploadsPath.file_path(new_filename)
sound_params = %{
filename: new_filename,
source_type: params["source_type"] || db_sound.source_type,
url: params["url"],
user_id: db_sound.user_id || user_id,
volume:
params["volume"]
|> Volume.percent_to_decimal(Volume.decimal_to_percent(db_sound.volume))
}
updated_sound =
case Sound.changeset(db_sound, sound_params) |> Repo.update() do
{:ok, updated_sound} ->
updated_sound = update_user_settings(db_sound, user_id, updated_sound, params)
AudioPlayer.invalidate_cache(db_sound.filename)
AudioPlayer.invalidate_cache(updated_sound.filename)
updated_sound
{:error, changeset} ->
Repo.rollback(changeset)
end
case maybe_rename_local_file(db_sound, old_path, new_path) do
:ok -> updated_sound
{:error, error} -> Repo.rollback(error)
end
end)
end
def delete_sound(%Sound{} = sound, user_id) do
db_sound = Repo.get!(Sound, sound.id)
with true <- db_sound.user_id == user_id,
{:ok, _deleted_sound} <- Repo.delete(db_sound) do
AudioPlayer.invalidate_cache(db_sound.filename)
maybe_remove_local_file(db_sound)
:ok
else
false -> {:error, :forbidden}
{:error, changeset} -> {:error, changeset}
end
end
defp maybe_remove_local_file(%{source_type: "local", filename: filename}) do
_ = File.rm(UploadsPath.file_path(filename))
:ok
end
defp maybe_remove_local_file(_), do: :ok
defp maybe_rename_local_file(%{source_type: "local"} = sound, old_path, new_path) do
cond do
sound.filename == Path.basename(new_path) ->
:ok
old_path == new_path ->
:ok
not File.exists?(old_path) ->
Logger.error("Source file not found: #{old_path}")
{:error, "Source file not found"}
true ->
case File.rename(old_path, new_path) do
:ok ->
:ok
{:error, reason} ->
Logger.error("File rename failed: #{inspect(reason)}")
{:error, "Failed to rename file: #{inspect(reason)}"}
end
end
end
defp maybe_rename_local_file(_, _, _), do: :ok
defp update_user_settings(sound, user_id, updated_sound, params) do
user_setting =
Enum.find(sound.user_sound_settings, &(&1.user_id == user_id)) ||
%Soundboard.UserSoundSetting{sound_id: sound.id, user_id: user_id}
setting_params = %{
user_id: user_id,
sound_id: sound.id,
is_join_sound: params["is_join_sound"] == "true",
is_leave_sound: params["is_leave_sound"] == "true"
}
Soundboard.UserSoundSetting.clear_conflicting_settings(
user_id,
sound.id,
setting_params.is_join_sound,
setting_params.is_leave_sound
)
case user_setting
|> Soundboard.UserSoundSetting.changeset(setting_params)
|> Repo.insert_or_update() do
{:ok, _setting} ->
updated_sound
{:error, changeset} ->
Logger.error("Failed to update user settings: #{inspect(changeset)}")
Repo.rollback(changeset)
end
end
end
================================================
FILE: lib/soundboard/sounds/tags.ex
================================================
defmodule Soundboard.Sounds.Tags do
@moduledoc """
Domain helpers for searching, resolving, and persisting sound tags.
"""
import Ecto.Changeset
alias Soundboard.{Repo, Sound, Tag}
def search(query) do
Tag.search(query)
|> Repo.all()
end
def all_for_sounds(sounds) do
sounds
|> Enum.flat_map(& &1.tags)
|> Enum.uniq_by(& &1.id)
|> Enum.sort_by(& &1.name)
end
def count_sounds_with_tag(sounds, tag) do
Enum.count(sounds, fn sound ->
Enum.any?(sound.tags, &(&1.id == tag.id))
end)
end
def tag_selected?(tag, selected_tags) do
Enum.any?(selected_tags, &(&1.id == tag.id))
end
def update_sound_tags(sound, tags) do
sound
|> Repo.preload(:tags)
|> Sound.changeset(%{tags: tags})
|> Repo.update()
end
def resolve_many(tags) when is_list(tags) do
tags
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn tag, {:ok, acc} ->
case resolve(tag) do
{:ok, nil} -> {:cont, {:ok, acc}}
{:ok, resolved_tag} -> {:cont, {:ok, [resolved_tag | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, tag_list} -> {:ok, Enum.reverse(tag_list) |> Enum.uniq_by(& &1.id)}
error -> error
end
end
def resolve_many(_), do: {:ok, []}
def resolve(%Tag{} = tag), do: {:ok, tag}
def resolve(tag_name) when is_binary(tag_name) do
normalized =
tag_name
|> String.trim()
|> String.downcase()
if normalized == "" do
{:error, add_error(change(%Sound{}), :tags, "can't be blank")}
else
find_or_create(normalized)
end
end
def resolve(_), do: {:ok, nil}
def find_or_create(name) when is_binary(name) do
normalized = name |> String.trim() |> String.downcase()
case Repo.get_by(Tag, name: normalized) do
%Tag{} = tag -> {:ok, tag}
nil -> insert_or_get(normalized)
end
end
def list_for_sound(filename) do
case Repo.get_by(Sound, filename: filename) do
nil -> []
sound -> sound |> Repo.preload(:tags) |> Map.get(:tags)
end
end
defp insert_or_get(name) do
case %Tag{} |> Tag.changeset(%{name: name}) |> Repo.insert() do
{:ok, tag} -> {:ok, tag}
{:error, _} -> fetch_after_insert_conflict(name)
end
end
defp fetch_after_insert_conflict(name) do
case Repo.get_by(Tag, name: name) do
%Tag{} = tag -> {:ok, tag}
nil -> {:error, add_error(change(%Sound{}), :tags, "is invalid")}
end
end
end
================================================
FILE: lib/soundboard/sounds/uploads/create_request.ex
================================================
defmodule Soundboard.Sounds.Uploads.CreateRequest do
@moduledoc false
alias Soundboard.Accounts.User
@enforce_keys [:user]
defstruct [
:user,
:source_type,
:name,
:url,
:upload,
:tags,
:volume,
:is_join_sound,
:is_leave_sound,
:default_volume_percent
]
@type upload ::
%Plug.Upload{}
| %{
optional(:path) => String.t(),
optional(:filename) => String.t(),
optional(:client_name) => String.t(),
optional(String.t()) => String.t()
}
@type t :: %__MODULE__{
user: User.t() | nil,
source_type: String.t() | nil,
name: String.t() | nil,
url: String.t() | nil,
upload: upload() | nil,
tags: [map() | String.t()] | nil,
volume: String.t() | number() | nil,
is_join_sound: boolean() | String.t() | nil,
is_leave_sound: boolean() | String.t() | nil,
default_volume_percent: String.t() | number() | nil
}
@spec new(User.t() | nil, map()) :: t()
def new(user, attrs \\ %{}) when is_map(attrs) do
%__MODULE__{
user: user,
source_type: get_param(attrs, :source_type),
name: get_param(attrs, :name),
url: get_param(attrs, :url),
upload: normalize_upload(get_param(attrs, :upload) || get_param(attrs, :file)),
tags: get_param(attrs, :tags) || get_param(attrs, "tags[]") || [],
volume: get_param(attrs, :volume),
is_join_sound: get_param(attrs, :is_join_sound),
is_leave_sound: get_param(attrs, :is_leave_sound),
default_volume_percent: get_param(attrs, :default_volume_percent)
}
end
@spec put_upload(t(), upload() | nil) :: t()
def put_upload(%__MODULE__{} = request, upload) do
struct!(request, upload: normalize_upload(upload))
end
defp normalize_upload(nil), do: nil
defp normalize_upload(upload) when is_map(upload) do
%{
path: get_param(upload, :path),
filename: get_param(upload, :filename) || get_param(upload, :client_name)
}
end
defp normalize_upload(_), do: nil
defp get_param(map, key, default \\ nil) do
Map.get(map, key, Map.get(map, to_string(key), default))
end
end
================================================
FILE: lib/soundboard/sounds/uploads/creator.ex
================================================
defmodule Soundboard.Sounds.Uploads.Creator do
@moduledoc false
alias Soundboard.{PubSubTopics, Repo, Sound, Stats, UserSoundSetting}
alias Soundboard.Sounds.Tags
alias Soundboard.Sounds.Uploads.Source
@spec create(map(), map()) :: {:ok, Sound.t()} | {:error, term()}
def create(params, source) do
Repo.transaction(fn ->
with {:ok, tags} <- Tags.resolve_many(params.tags),
{:ok, sound} <- insert_sound(params, source, tags),
{:ok, _setting} <- insert_user_setting(sound, params),
sound <- Repo.preload(sound, [:tags, :user, :user_sound_settings]) do
sound
else
{:error, reason} -> Repo.rollback(reason)
end
end)
|> case do
{:ok, sound} ->
broadcast_updates()
{:ok, sound}
{:error, reason} ->
Source.cleanup_local_file(source.copied_file_path)
{:error, reason}
end
end
defp insert_sound(params, source, tags) do
sound_attrs = %{
filename: source.filename,
source_type: source.source_type,
url: source.url,
user_id: params.user.id,
volume: params.volume,
tags: tags
}
%Sound{}
|> Sound.changeset(sound_attrs)
|> Repo.insert()
end
defp insert_user_setting(sound, params) do
attrs = %{
user_id: params.user.id,
sound_id: sound.id,
is_join_sound: params.is_join_sound,
is_leave_sound: params.is_leave_sound
}
UserSoundSetting.clear_conflicting_settings(
params.user.id,
sound.id,
params.is_join_sound,
params.is_leave_sound
)
%UserSoundSetting{}
|> UserSoundSetting.changeset(attrs)
|> Repo.insert()
end
defp broadcast_updates do
PubSubTopics.broadcast_files_updated()
Stats.broadcast_stats_update()
end
end
================================================
FILE: lib/soundboard/sounds/uploads/normalizer.ex
================================================
defmodule Soundboard.Sounds.Uploads.Normalizer do
@moduledoc false
import Ecto.Changeset
alias Soundboard.{Sound, Volume}
alias Soundboard.Sounds.Uploads.CreateRequest
@spec normalize(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def normalize(%CreateRequest{} = request) do
case request.user do
%Soundboard.Accounts.User{} = user ->
source_type = normalize_source_type(request.source_type, request.upload, request.url)
name = normalize_name(request.name)
build_normalized_params(%{
user: user,
source_type: source_type,
name: name,
url: normalize_url(request.url),
tags: request.tags,
volume: request.volume,
is_join_sound: request.is_join_sound,
is_leave_sound: request.is_leave_sound,
default_volume_percent: request.default_volume_percent || 100,
upload: request.upload
})
_ ->
{:error, add_error(change(%Sound{}), :user_id, "can't be blank")}
end
end
defp build_normalized_params(%{
user: %Soundboard.Accounts.User{} = user,
source_type: source_type,
name: name,
url: url,
tags: tags,
volume: volume,
is_join_sound: is_join_sound,
is_leave_sound: is_leave_sound,
default_volume_percent: default_volume_percent,
upload: upload
}) do
if blank?(name) do
{:error, add_error(change(%Sound{}), :filename, "can't be blank")}
else
{:ok,
%{
user: user,
source_type: source_type,
name: name,
url: url,
tags: normalize_tags(tags),
volume:
Volume.percent_to_decimal(volume, normalize_default_volume(default_volume_percent)),
is_join_sound: to_boolean(is_join_sound),
is_leave_sound: to_boolean(is_leave_sound),
upload: upload
}}
end
end
defp build_normalized_params(_params) do
{:error, add_error(change(%Sound{}), :user_id, "can't be blank")}
end
defp normalize_default_volume(value), do: Volume.normalize_percent(value, 100)
defp normalize_tags(nil), do: []
defp normalize_tags(tags) when is_binary(tags) do
tags
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp normalize_tags(tags) when is_list(tags), do: tags
defp normalize_tags(_), do: []
defp normalize_source_type(source_type, upload, url) when is_binary(source_type) do
case source_type |> String.trim() |> String.downcase() do
"local" -> "local"
"url" -> "url"
_ -> infer_source_type(upload, url)
end
end
defp normalize_source_type(_source_type, upload, url), do: infer_source_type(upload, url)
defp infer_source_type(upload, url) do
cond do
is_map(upload) -> "local"
is_binary(url) and String.trim(url) != "" -> "url"
true -> "local"
end
end
defp normalize_name(name) when is_binary(name), do: String.trim(name)
defp normalize_name(_), do: nil
defp normalize_url(url) when is_binary(url), do: String.trim(url)
defp normalize_url(_), do: nil
defp to_boolean(value) when value in [true, "true", "1", 1, "on", "yes"], do: true
defp to_boolean(_), do: false
defp blank?(value), do: value in [nil, ""]
end
================================================
FILE: lib/soundboard/sounds/uploads/source.ex
================================================
defmodule Soundboard.Sounds.Uploads.Source do
@moduledoc false
import Ecto.Changeset
import Ecto.Query
require Logger
alias Soundboard.{Repo, Sound, UploadsPath}
@allowed_extensions ~w(.mp3 .wav .ogg .m4a)
@spec prepare(map(), :validate | :create) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def prepare(%{source_type: "url"} = params, _mode) do
with {:ok, url} <- validate_url(params.url),
filename <- params.name <> url_file_extension(url),
:ok <- validate_destination_filename(filename) do
{:ok,
%{
filename: filename,
source_type: "url",
url: url,
copied_file_path: nil
}}
end
end
def prepare(%{source_type: "local"} = params, :validate) do
with {:ok, upload} <- validate_local_upload(params.upload, :validate),
{:ok, ext} <- validate_local_extension(upload.filename),
filename <- params.name <> ext,
:ok <- validate_destination_filename(filename) do
{:ok,
%{
filename: filename,
source_type: "local",
url: nil,
copied_file_path: nil
}}
end
end
def prepare(%{source_type: "local"} = params, :create) do
with {:ok, upload} <- validate_local_upload(params.upload, :create),
{:ok, ext} <- validate_local_extension(upload.filename),
filename <- params.name <> ext,
:ok <- validate_destination_filename(filename),
{:ok, copied_file_path} <- copy_local_file(upload.path, filename) do
{:ok,
%{
filename: filename,
source_type: "local",
url: nil,
copied_file_path: copied_file_path
}}
end
end
def prepare(_params, _mode) do
{:error, add_error(change(%Sound{}), :source_type, "must be either 'local' or 'url'")}
end
@spec cleanup_local_file(String.t() | nil) :: :ok
def cleanup_local_file(path) when is_binary(path) do
case File.rm(path) do
:ok ->
:ok
{:error, reason} ->
Logger.warning("Failed to clean up copied upload #{path}: #{inspect(reason)}")
:ok
end
end
def cleanup_local_file(_path), do: :ok
defp validate_url(url) when is_binary(url) do
if blank?(url) do
{:error, add_error(change(%Sound{}), :url, "can't be blank")}
else
{:ok, url}
end
end
defp validate_url(_url), do: {:error, add_error(change(%Sound{}), :url, "can't be blank")}
defp validate_local_upload(nil, _mode),
do: {:error, add_error(change(%Sound{}), :file, "Please select a file")}
defp validate_local_upload(%{filename: filename} = upload, :validate) do
if blank?(filename) do
{:error, add_error(change(%Sound{}), :file, "Please select a file")}
else
{:ok, %{path: Map.get(upload, :path), filename: filename}}
end
end
defp validate_local_upload(%{path: path, filename: filename}, :create) when is_binary(path) do
if blank?(filename) do
{:error, add_error(change(%Sound{}), :file, "Invalid file upload")}
else
{:ok, %{path: path, filename: filename}}
end
end
defp validate_local_upload(_, _mode),
do: {:error, add_error(change(%Sound{}), :file, "Please select a file")}
defp validate_local_extension(filename) do
ext = filename |> Path.extname() |> String.downcase()
if ext in @allowed_extensions do
{:ok, ext}
else
{:error,
add_error(
change(%Sound{}),
:file,
"Invalid file type. Please upload an MP3, WAV, OGG, or M4A file."
)}
end
end
defp copy_local_file(src_path, filename) do
uploads_dir = UploadsPath.dir()
dest_path = UploadsPath.file_path(filename)
with :ok <- ensure_uploads_dir(uploads_dir),
:ok <- File.cp(src_path, dest_path) do
{:ok, dest_path}
else
{:error, _reason} ->
{:error, add_error(change(%Sound{}), :file, "Error saving file")}
end
end
defp ensure_uploads_dir(uploads_dir) do
case File.mkdir_p(uploads_dir) do
:ok -> :ok
{:error, _reason} -> {:error, add_error(change(%Sound{}), :file, "Error saving file")}
end
end
defp validate_destination_filename(filename) do
dest_path = UploadsPath.file_path(filename)
if filename_taken?(filename) or File.exists?(dest_path) do
{:error, add_error(change(%Sound{}), :filename, "has already been taken")}
else
:ok
end
end
defp filename_taken?(filename) do
from(s in Sound, where: s.filename == ^filename)
|> Repo.exists?()
end
defp url_file_extension(url) when is_binary(url) do
ext =
url
|> URI.parse()
|> Map.get(:path)
|> case do
nil -> ""
path -> String.downcase(Path.extname(path || ""))
end
if ext in @allowed_extensions, do: ext, else: ""
end
defp url_file_extension(_), do: ""
defp blank?(value), do: value in [nil, ""]
end
================================================
FILE: lib/soundboard/sounds/uploads.ex
================================================
defmodule Soundboard.Sounds.Uploads do
@moduledoc """
Canonical sound upload/create API.
"""
import Ecto.Changeset
alias Soundboard.Sound
alias Soundboard.Sounds.Uploads.{CreateRequest, Creator, Normalizer, Source}
@type create_error :: Ecto.Changeset.t()
@type create_result :: {:ok, Sound.t()} | {:error, create_error()}
@spec validate(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def validate(%CreateRequest{} = request) do
with {:ok, params} <- Normalizer.normalize(request),
{:ok, _source} <- Source.prepare(params, :validate) do
{:ok, params}
end
end
@spec create(CreateRequest.t()) :: create_result()
def create(%CreateRequest{} = request) do
with {:ok, params} <- Normalizer.normalize(request),
{:ok, source} <- Source.prepare(params, :create),
{:ok, sound} <- Creator.create(params, source) do
{:ok, sound}
else
{:error, reason} -> {:error, normalize_create_error(reason)}
end
end
@spec error_message(Ecto.Changeset.t() | String.t() | term()) :: String.t()
def error_message(%Ecto.Changeset{} = changeset) do
Enum.map_join(changeset.errors, ", ", fn
{:filename, {"has already been taken", _}} -> "A sound with that name already exists"
{:file, {"Please select a file", _}} -> "Please select a file"
{key, {msg, _}} -> "#{key} #{msg}"
end)
end
def error_message(error) when is_binary(error), do: error
def error_message(_), do: "An unexpected error occurred"
defp normalize_create_error(%Ecto.Changeset{} = changeset), do: changeset
defp normalize_create_error(message) when is_binary(message), do: add_base_error(message)
defp normalize_create_error(_reason), do: add_base_error("An unexpected error occurred")
defp add_base_error(message) do
change(%Sound{})
|> add_error(:base, message)
end
end
================================================
FILE: lib/soundboard/sounds.ex
================================================
defmodule Soundboard.Sounds do
@moduledoc """
Sound domain context.
"""
import Ecto.Query
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound}
alias Soundboard.Sounds.{Management, Uploads}
alias Soundboard.Sounds.Uploads.CreateRequest
@detailed_preloads [
:tags,
:user,
user_sound_settings: [user: []]
]
@spec list_files() :: [Sound.t()]
def list_files do
Sound
|> Sound.with_tags()
|> preload(:user_sound_settings)
|> Repo.all()
end
@spec list_detailed() :: [Sound.t()]
def list_detailed do
Sound
|> Repo.all()
|> Repo.preload(@detailed_preloads)
|> Enum.sort_by(&String.downcase(&1.filename))
end
@spec fetch_sound_id(String.t()) :: {:ok, integer()} | :error
def fetch_sound_id(filename) when is_binary(filename) do
case Repo.get_by(Sound, filename: filename) do
nil -> :error
sound -> {:ok, sound.id}
end
end
def ids_by_filename([]), do: %{}
@spec ids_by_filename([String.t()]) :: %{optional(String.t()) => integer()}
def ids_by_filename(filenames) when is_list(filenames) do
from(s in Sound, where: s.filename in ^filenames, select: {s.filename, s.id})
|> Repo.all()
|> Map.new()
end
@spec filename_taken?(String.t()) :: boolean()
def filename_taken?(filename) when is_binary(filename) do
Repo.exists?(from s in Sound, where: s.filename == ^filename)
end
@spec filename_taken_excluding?(String.t(), integer() | String.t()) :: boolean()
def filename_taken_excluding?(filename, sound_id) do
from(s in Sound, where: s.filename == ^filename and s.id != ^sound_id)
|> Repo.exists?()
end
@spec filename_conflicts_across_extensions?(String.t(), [String.t()]) :: boolean()
def filename_conflicts_across_extensions?(base_name, extensions) when is_list(extensions) do
names = Enum.map(extensions, &(base_name <> &1))
from(s in Sound, where: s.filename in ^names)
|> Repo.exists?()
end
@spec fetch_filename_extension(term()) :: {:ok, String.t()} | :error
def fetch_filename_extension(sound_id) do
case Repo.get(Sound, sound_id) do
%Sound{filename: filename} -> {:ok, Path.extname(filename)}
_ -> :error
end
end
@spec get_recent_uploads(keyword()) :: [{String.t(), String.t(), NaiveDateTime.t()}]
def get_recent_uploads(opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
from(s in Sound,
join: u in User,
on: s.user_id == u.id,
select: {s.filename, u.username, s.inserted_at},
order_by: [desc: s.inserted_at],
limit: ^limit
)
|> Repo.all()
end
@spec get_user_join_sound(integer()) :: String.t() | nil
def get_user_join_sound(user_id) do
Repo.one(
from uss in Soundboard.UserSoundSetting,
join: s in Sound,
on: uss.sound_id == s.id,
where: uss.user_id == ^user_id and uss.is_join_sound == true,
select: s.filename
)
end
@spec get_user_leave_sound(integer()) :: String.t() | nil
def get_user_leave_sound(user_id) do
Repo.one(
from uss in Soundboard.UserSoundSetting,
join: s in Sound,
on: uss.sound_id == s.id,
where: uss.user_id == ^user_id and uss.is_leave_sound == true,
select: s.filename
)
end
@spec get_user_join_sound_by_discord_id(term()) :: String.t() | nil
def get_user_join_sound_by_discord_id(discord_id) do
Repo.one(
from u in User,
where: u.discord_id == ^to_string(discord_id),
left_join: uss in Soundboard.UserSoundSetting,
on: uss.user_id == u.id and uss.is_join_sound == true,
left_join: s in Sound,
on: s.id == uss.sound_id,
select: s.filename,
limit: 1
)
end
@spec get_user_leave_sound_by_discord_id(term()) :: String.t() | nil
def get_user_leave_sound_by_discord_id(discord_id) do
Repo.one(
from u in User,
where: u.discord_id == ^to_string(discord_id),
left_join: uss in Soundboard.UserSoundSetting,
on: uss.user_id == u.id and uss.is_leave_sound == true,
left_join: s in Sound,
on: s.id == uss.sound_id,
select: s.filename,
limit: 1
)
end
@spec get_user_sound_preferences_by_discord_id(term()) :: map() | nil
def get_user_sound_preferences_by_discord_id(discord_id) do
case Repo.get_by(User, discord_id: to_string(discord_id)) do
nil ->
nil
user ->
%{
user_id: user.id,
join_sound: get_user_join_sound(user.id),
leave_sound: get_user_leave_sound(user.id)
}
end
end
@spec get_sound!(term()) :: Sound.t()
def get_sound!(id) do
Sound
|> Repo.get!(id)
|> Repo.preload(@detailed_preloads)
end
@spec update_sound(Sound.t(), map()) :: {:ok, Sound.t()} | {:error, Ecto.Changeset.t()}
def update_sound(sound, attrs) do
sound
|> Sound.changeset(attrs)
|> Repo.update()
end
@spec update_sound(Sound.t(), integer(), map()) :: {:ok, Sound.t()} | {:error, term()}
def update_sound(sound, user_id, params), do: Management.update_sound(sound, user_id, params)
@spec delete_sound(Sound.t(), integer()) :: :ok | {:error, term()}
def delete_sound(sound, user_id), do: Management.delete_sound(sound, user_id)
@spec new_create_request(User.t() | nil, map()) :: CreateRequest.t()
def new_create_request(user, attrs), do: CreateRequest.new(user, attrs)
@spec put_request_upload(CreateRequest.t(), map() | nil) :: CreateRequest.t()
def put_request_upload(request, upload), do: CreateRequest.put_upload(request, upload)
@spec validate_create(CreateRequest.t()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def validate_create(request), do: Uploads.validate(request)
@spec create_sound(CreateRequest.t()) :: {:ok, Sound.t()} | {:error, Ecto.Changeset.t()}
def create_sound(request), do: Uploads.create(request)
@spec create_error_message(Ecto.Changeset.t() | String.t() | term()) :: String.t()
def create_error_message(error), do: Uploads.error_message(error)
end
================================================
FILE: lib/soundboard/stats/play.ex
================================================
defmodule Soundboard.Stats.Play do
@moduledoc """
The Play module.
"""
use Ecto.Schema
import Ecto.Changeset
alias Soundboard.Accounts.User
alias Soundboard.Sound
schema "plays" do
field :played_filename, :string
belongs_to :sound, Sound
belongs_to :user, User
timestamps()
end
def changeset(play, attrs) do
play
|> cast(attrs, [:played_filename, :sound_id, :user_id])
|> validate_required([:played_filename, :sound_id, :user_id])
|> assoc_constraint(:sound)
end
end
================================================
FILE: lib/soundboard/stats.ex
================================================
defmodule Soundboard.Stats do
@moduledoc """
Handles the stats of the soundboard.
"""
import Ecto.Query
import Ecto.Changeset, only: [add_error: 3, change: 1]
alias Soundboard.{Accounts.User, PubSubTopics, Repo, Sounds, Stats.Play}
@type leaderboard_entry :: {String.t(), non_neg_integer()}
@type recent_play_entry :: {integer(), String.t(), String.t(), NaiveDateTime.t()}
@spec track_play(String.t(), integer() | nil) :: {:ok, Play.t()} | {:error, Ecto.Changeset.t()}
def track_play(sound_name, user_id) do
with {:ok, sound_id} <- Sounds.fetch_sound_id(sound_name),
{:ok, play} <-
insert_play(%{played_filename: sound_name, sound_id: sound_id, user_id: user_id}) do
broadcast_stats_update()
{:ok, play}
else
:error -> {:error, add_error(change(%Play{}), :sound_id, "can't be blank")}
{:error, _changeset} = result -> result
end
end
defp get_week_range do
today = Date.utc_today()
days_since_monday = Date.day_of_week(today, :monday)
start_date = Date.add(today, -days_since_monday + 1)
end_date = Date.add(start_date, 6)
{
DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"),
DateTime.new!(end_date, ~T[23:59:59], "Etc/UTC")
}
end
@spec get_top_users(Date.t(), Date.t(), keyword()) :: [leaderboard_entry()]
def get_top_users(start_date, end_date, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
from(p in Play,
join: u in assoc(p, :user),
where: fragment("DATE(?) BETWEEN ? AND ?", p.inserted_at, ^start_date, ^end_date),
group_by: u.username,
select: {u.username, count(p.id)},
order_by: [desc: count(p.id)],
limit: ^limit
)
|> Repo.all()
end
@spec get_top_sounds(Date.t(), Date.t(), keyword()) :: [leaderboard_entry()]
def get_top_sounds(start_date, end_date, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
from(p in Play,
where: fragment("DATE(?) BETWEEN ? AND ?", p.inserted_at, ^start_date, ^end_date),
group_by: p.played_filename,
select: {p.played_filename, count(p.id)},
order_by: [desc: count(p.id)],
limit: ^limit
)
|> Repo.all()
end
@spec get_recent_plays(keyword()) :: [recent_play_entry()]
def get_recent_plays(opts \\ []) do
limit = Keyword.get(opts, :limit, 5)
from(p in Play,
join: u in User,
on: p.user_id == u.id,
select: {p.id, p.played_filename, u.username, p.inserted_at},
order_by: [desc: p.inserted_at, desc: p.id],
limit: ^limit
)
|> Repo.all()
end
@spec reset_weekly_stats() :: :ok | {:error, term()}
def reset_weekly_stats do
{week_start, _week_end} = get_week_range()
from(p in Play, where: p.inserted_at < ^week_start)
|> Repo.delete_all()
broadcast_stats_update()
end
@spec broadcast_stats_update() :: :ok | {:error, term()}
def broadcast_stats_update do
PubSubTopics.broadcast_stats_updated()
end
defp insert_play(attrs) do
%Play{}
|> Play.changeset(attrs)
|> Repo.insert()
end
end
================================================
FILE: lib/soundboard/tag.ex
================================================
defmodule Soundboard.Tag do
@moduledoc """
The Tag module.
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
schema "tags" do
field :name, :string
many_to_many :sounds, Soundboard.Sound,
join_through: Soundboard.SoundTag,
on_replace: :delete
timestamps()
end
def changeset(tag, attrs) do
tag
|> cast(attrs, [:name])
|> validate_required([:name])
|> unique_constraint(:name)
end
def search(query \\ __MODULE__, search_term) do
from t in query,
where: like(fragment("lower(?)", t.name), ^"%#{String.downcase(search_term)}%"),
order_by: t.name,
limit: 10
end
end
================================================
FILE: lib/soundboard/uploads_path.ex
================================================
defmodule Soundboard.UploadsPath do
@moduledoc """
Central source of truth for uploaded sound storage paths.
"""
@default_relative_dir "priv/static/uploads"
@type path_input :: String.t() | [String.t()]
def dir do
Application.get_env(:soundboard, :uploads_dir, @default_relative_dir)
|> expand_dir()
end
def file_path(filename) when is_binary(filename) do
Path.join(dir(), filename)
end
def joined_path(path_segments) when is_list(path_segments) do
Path.join([dir() | path_segments])
end
def joined_path(path) when is_binary(path) do
Path.join(dir(), path)
end
@spec safe_joined_path(path_input()) :: {:ok, String.t()} | :error
def safe_joined_path(path) do
base_dir = dir() |> Path.expand()
candidate =
path
|> normalize_path_segments()
|> then(&Path.join([base_dir | &1]))
|> Path.expand()
if within_uploads_dir?(candidate, base_dir) do
{:ok, candidate}
else
:error
end
end
defp normalize_path_segments(path) when is_binary(path), do: [path]
defp normalize_path_segments(path_segments) when is_list(path_segments), do: path_segments
defp within_uploads_dir?(candidate, base_dir) do
candidate == base_dir or String.starts_with?(candidate, base_dir <> "/")
end
defp expand_dir(path) when is_binary(path) do
case Path.type(path) do
:absolute -> path
_ -> Application.app_dir(:soundboard, path)
end
end
end
================================================
FILE: lib/soundboard/user_sound_setting.ex
================================================
defmodule Soundboard.UserSoundSetting do
@moduledoc """
The UserSoundSetting module.
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Soundboard.Repo
schema "user_sound_settings" do
belongs_to :user, Soundboard.Accounts.User
belongs_to :sound, Soundboard.Sound
field :is_join_sound, :boolean, default: false
field :is_leave_sound, :boolean, default: false
timestamps()
end
def changeset(settings, attrs) do
settings
|> cast(attrs, [:user_id, :sound_id, :is_join_sound, :is_leave_sound])
|> validate_required([:user_id, :sound_id])
end
def clear_conflicting_settings(user_id, sound_id, is_join_sound, is_leave_sound) do
maybe_clear_join_sound(user_id, sound_id, is_join_sound)
maybe_clear_leave_sound(user_id, sound_id, is_leave_sound)
:ok
end
defp maybe_clear_join_sound(user_id, sound_id, true) do
from(uss in __MODULE__,
where:
uss.user_id == ^user_id and
uss.sound_id != ^sound_id and
uss.is_join_sound == true
)
|> Repo.update_all(set: [is_join_sound: false])
:ok
end
defp maybe_clear_join_sound(_user_id, _sound_id, _is_join_sound), do: :ok
defp maybe_clear_leave_sound(user_id, sound_id, true) do
from(uss in __MODULE__,
where:
uss.user_id == ^user_id and
uss.sound_id != ^sound_id and
uss.is_leave_sound == true
)
|> Repo.update_all(set: [is_leave_sound: false])
:ok
end
defp maybe_clear_leave_sound(_user_id, _sound_id, _is_leave_sound), do: :ok
end
================================================
FILE: lib/soundboard/volume.ex
================================================
defmodule Soundboard.Volume do
@moduledoc """
Helpers for working with volume percentages and decimal ratios.
"""
@type percent :: 0..150
@spec clamp_percent(number()) :: percent()
def clamp_percent(value) do
value
|> round()
|> min(150)
|> max(0)
end
@spec normalize_percent(String.t() | number() | nil, percent()) :: percent()
def normalize_percent(value, default_percent) do
default_percent
|> clamp_percent()
|> do_normalize(value)
end
@spec percent_to_decimal(String.t() | number() | nil) :: float()
def percent_to_decimal(percent), do: percent_to_decimal(percent, 100)
@spec percent_to_decimal(String.t() | number() | nil, percent()) :: float()
def percent_to_decimal(value, default_percent) do
value
|> normalize_percent(default_percent)
|> convert_percent_to_decimal()
end
@spec decimal_to_percent(number() | nil) :: percent()
def decimal_to_percent(nil), do: 100
def decimal_to_percent(decimal) when is_number(decimal) do
decimal
|> max(0.0)
|> min(1.5)
|> do_decimal_to_percent()
end
defp do_decimal_to_percent(value) when value <= 1.0 do
value
|> Kernel.*(100)
|> clamp_percent()
end
defp do_decimal_to_percent(value) do
value
|> Kernel.-(1.0)
|> Kernel.*(100)
|> Kernel.+(100)
|> clamp_percent()
end
defp do_normalize(default, nil), do: default
defp do_normalize(_default, value) when is_integer(value), do: clamp_percent(value)
defp do_normalize(_default, value) when is_float(value), do: clamp_percent(value)
defp do_normalize(default, value) when is_binary(value) do
value
|> String.trim()
|> Float.parse()
|> case do
{parsed, _rest} -> clamp_percent(parsed)
:error -> default
end
end
defp do_normalize(default, _), do: default
defp convert_percent_to_decimal(percent) when percent in 0..150 do
cond do
percent == 0 ->
0.0
percent <= 100 ->
percent
|> Kernel./(100)
|> Float.round(4)
true ->
Float.round(1.0 + (percent - 100) * 0.01, 4)
end
end
end
================================================
FILE: lib/soundboard.ex
================================================
defmodule Soundboard do
@moduledoc """
Soundboard keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
@doc """
Returns the application name.
"""
def app_name, do: :soundboard
end
================================================
FILE: lib/soundboard_web/components/core_components.ex
================================================
defmodule SoundboardWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as modals, tables, and
forms. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The default components use Tailwind CSS, a utility-first CSS framework.
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
how to customize them or feel free to swap in another framework altogether.
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
use Gettext, backend: SoundboardWeb.Gettext
alias Phoenix.HTML.Form
alias Phoenix.LiveView.JS
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
This is a modal.
JS commands may be passed to the `:on_cancel` to configure
the closing/cancel event, for example:
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
This is another modal.
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
def modal(assigns) do
~H"""
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
{render_slot(@inner_block)}
"""
end
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
hide("##{@id}")}
role="alert"
class={[
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
{@rest}
>
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
{@title}
{msg}
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error")}
phx-connected={hide("#client-error")}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error")}
phx-connected={hide("#server-error")}
hidden
>
{gettext("Hang in there while we get back on track")}
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
"""
end
@doc """
Renders a simple form.
## Examples
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email"/>
<.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save
"""
attr :for, :any, required: true, doc: "the data structure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
{render_slot(@inner_block, f)}
{render_slot(action, f)}
"""
end
@doc """
Renders a button.
## Examples
<.button>Send!
<.button phx-click="go" class="ml-2">Send!
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
slot :inner_block, required: true
def button(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
range search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
{@label}
<.error :for={msg <- @errors}>{msg}
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<.label for={@id}>{@label}
{@prompt}
{Phoenix.HTML.Form.options_for_select(@options, @value)}
<.error :for={msg <- @errors}>{msg}
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<.label for={@id}>{@label}
<.error :for={msg <- @errors}>{msg}
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<.label for={@id}>{@label}
<.error :for={msg <- @errors}>{msg}
"""
end
@doc """
Renders a label.
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
@doc """
Generates a generic error message.
"""
slot :inner_block, required: true
def error(assigns) do
~H"""
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
{render_slot(@inner_block)}
"""
end
@doc """
Renders a header with title.
"""
attr :class, :string, default: nil
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
{render_slot(@inner_block)}
{render_slot(@subtitle)}
{render_slot(@actions)}
"""
end
@doc ~S"""
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}
<:col :let={user} label="username">{user.username}
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
{col[:label]}
{gettext("Actions")}
{render_slot(col, @row_item.(row))}
{render_slot(action, @row_item.(row))}
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}
<:item title="Views">{@post.views}
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
{item.title}
{render_slot(item)}
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
def back(assigns) do
~H"""
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
{render_slot(@inner_block)}
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles – outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
## Examples
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: nil
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all transform ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
time: 300,
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-content")
end
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(SoundboardWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(SoundboardWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
================================================
FILE: lib/soundboard_web/components/flash_component.ex
================================================
defmodule SoundboardWeb.Components.FlashComponent do
@moduledoc """
The flash component.
"""
use Phoenix.Component
def flash(assigns) do
~H"""
<%= if message = Phoenix.Flash.get(@flash, :info) do %>
<% end %>
<%= if message = Phoenix.Flash.get(@flash, :error) do %>
<% end %>
"""
end
end
================================================
FILE: lib/soundboard_web/components/layouts/app.html.heex
================================================
<.live_component
module={SoundboardWeb.Components.Layouts.Navbar}
id="navbar"
current_user={@current_user}
current_path={@current_path}
presences={@presences}
/>
<.flash_group flash={@flash} />
{@inner_content}
================================================
FILE: lib/soundboard_web/components/layouts/navbar.ex
================================================
defmodule SoundboardWeb.Components.Layouts.Navbar do
@moduledoc """
The navbar component.
"""
use Phoenix.LiveComponent
use SoundboardWeb, :html
@impl true
def mount(socket) do
{:ok, assign(socket, :show_mobile_menu, false)}
end
@impl true
def handle_event("toggle-mobile-menu", _, socket) do
{:noreply, assign(socket, :show_mobile_menu, !socket.assigns.show_mobile_menu)}
end
@impl true
def render(assigns) do
~H"""
<.link navigate="/">SoundBored
<.nav_link navigate="/" active={current_page?(@current_path, "/")}>
Sounds
<.nav_link navigate="/favorites" active={current_page?(@current_path, "/favorites")}>
Favorites
<.nav_link navigate="/stats" active={current_page?(@current_path, "/stats")}>
Stats
<%= if @current_user do %>
<.nav_link
navigate="/settings"
active={current_page?(@current_path, "/settings")}
>
Settings
<% end %>
<%= visible_users(@presences)
|> Enum.map(fn user -> %>
{user.username}
<% end) %>
"""
end
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"inline-flex items-center px-1 pt-1 text-sm font-medium",
if(@active,
do: "border-b-2 border-blue-500 text-gray-900 dark:text-gray-100",
else:
"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"
)
]}
>
{render_slot(@inner_block)}
"""
end
defp mobile_nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"block pl-4 pr-4 py-3 border-l-4 text-base font-medium leading-relaxed tracking-wide",
if(@active,
do: "bg-blue-50 dark:bg-blue-900/50 border-blue-500 text-blue-700 dark:text-blue-100",
else:
"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"
)
]}
>
{render_slot(@inner_block)}
"""
end
defp visible_users(presences) do
presences
|> Enum.flat_map(fn {_id, presence} ->
Enum.map(presence.metas, & &1.user)
end)
|> Enum.uniq_by(& &1.username)
end
defp current_page?(current_path, path), do: current_path == path
end
================================================
FILE: lib/soundboard_web/components/layouts/root.html.heex
================================================
<.live_title suffix="">
{assigns[:page_title] || "Soundboard"}
{@inner_content}
================================================
FILE: lib/soundboard_web/components/layouts.ex
================================================
defmodule SoundboardWeb.Layouts do
@moduledoc false
use SoundboardWeb, :html
embed_templates "layouts/*"
end
================================================
FILE: lib/soundboard_web/components/soundboard/delete_modal.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.DeleteModal do
@moduledoc """
The delete modal component.
"""
use Phoenix.Component
def delete_modal(assigns) do
~H"""
<%= if @show_delete_confirm do %>
Delete Sound
Are you sure you want to delete this sound? This action cannot be undone.
Cancel
Delete
<% end %>
"""
end
end
================================================
FILE: lib/soundboard_web/components/soundboard/edit_modal.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.EditModal do
@moduledoc """
The edit modal component.
"""
use Phoenix.Component
alias Soundboard.Volume
alias SoundboardWeb.Components.Soundboard.{TagComponents, VolumeControl}
attr :flash, :map, default: %{}
attr :edit_name_error, :string, default: nil
attr :current_user, :map, required: true
attr :current_sound, :map, required: true
attr :tag_input, :string, default: ""
attr :tag_suggestions, :list, default: []
def edit_modal(assigns) do
assigns = assign_new(assigns, :edit_name_error, fn -> nil end)
assigns =
update(assigns, :current_sound, fn sound ->
tags =
case sound.tags do
tags when is_list(tags) -> tags
_ -> []
end
Map.put(sound, :tags, tags)
end)
~H"""
"""
end
end
================================================
FILE: lib/soundboard_web/components/soundboard/helpers.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.Helpers do
@moduledoc """
Helper functions for the soundboard.
"""
def format_bytes(bytes) when is_integer(bytes) do
cond do
bytes >= 1_000_000 -> "#{Float.round(bytes / 1_000_000, 1)} MB"
bytes >= 1_000 -> "#{Float.round(bytes / 1_000, 1)} KB"
true -> "#{bytes} B"
end
end
end
================================================
FILE: lib/soundboard_web/components/soundboard/tag_components.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.TagComponents do
@moduledoc """
Shared tag UI helpers for the soundboard modals.
"""
use Phoenix.Component
alias SoundboardWeb.Live.Support.LiveTags
attr :tags, :list, default: []
attr :remove_event, :string, required: true
attr :tag_key, :atom, default: :name
attr :wrapper_class, :string, default: "mt-2 flex flex-wrap gap-2"
def tag_badge_list(assigns) do
assigns = assign_new(assigns, :tag_key, fn -> :name end)
~H"""
<%= for tag <- @tags do %>
<% tag_name = tag_value(tag, @tag_key) %>
{tag_name}
<% end %>
"""
end
attr :tag_input, :string, default: ""
attr :tag_suggestions, :list, default: []
attr :select_event, :string, required: true
attr :tag_key, :atom, default: :name
attr :wrapper_class, :string,
default:
"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"
attr :suggestion_class, :string,
default:
"w-full text-left px-4 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900 dark:text-gray-100"
def tag_suggestions_dropdown(assigns) do
assigns = assign_new(assigns, :tag_input, fn -> "" end)
~H"""
<%= if String.trim(@tag_input || "") != "" and @tag_suggestions != [] do %>
<%= for tag <- @tag_suggestions do %>
<% tag_name = tag_value(tag, @tag_key) %>
{tag_name}
<% end %>
<% end %>
"""
end
attr :tag, :any, required: true
attr :selected_tags, :list, required: true
attr :uploaded_files, :list, required: true
attr :tag_key, :atom, default: :name
attr :click_event, :string, default: "toggle_tag_filter"
attr :class, :any, default: []
def tag_filter_button(assigns) do
assigns = assign_new(assigns, :tag_key, fn -> :name end)
~H"""
{tag_value(@tag, @tag_key)}
({LiveTags.count_sounds_with_tag(@uploaded_files, @tag)})
"""
end
attr :value, :string, default: ""
attr :placeholder, :string, default: "Type a tag and press Enter..."
attr :input_id, :string, default: nil
attr :disabled, :boolean, default: false
attr :class, :string, default: ""
attr :onkeydown, :string, default: nil
attr :autocomplete, :string, default: nil
attr :rest, :global
def tag_input_field(assigns) do
assigns = assign_new(assigns, :value, fn -> "" end)
base_class =
"block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 " <>
"focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400"
assigns = assign(assigns, :base_class, base_class)
~H"""
"""
end
defp tag_value(tag, tag_key) when is_atom(tag_key) do
case tag do
%{^tag_key => value} -> value
%{} -> Map.get(tag, :name) || tag
_ -> tag
end
end
defp tag_value(tag, _tag_key), do: tag
end
================================================
FILE: lib/soundboard_web/components/soundboard/upload_modal.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.UploadModal do
@moduledoc """
The upload modal component.
"""
use Phoenix.Component
alias SoundboardWeb.Components.Soundboard.{TagComponents, VolumeControl}
def upload_modal(assigns) do
~H"""
"""
end
defp source_input_ready?("local", entries, _url), do: entries != []
defp source_input_ready?("url", _entries, url), do: String.trim(url || "") != ""
defp source_input_ready?(_, _entries, _url), do: false
defp form_ready?(source_type, entries, url, upload_error) do
source_input_ready?(source_type, entries, url) and is_nil(upload_error)
end
defp local_upload_pending?(source_type, entries), do: source_type == "local" and entries == []
defp url_upload_pending?(source_type, url),
do: source_type == "url" and String.trim(url || "") == ""
end
================================================
FILE: lib/soundboard_web/components/soundboard/volume_control.ex
================================================
defmodule SoundboardWeb.Components.Soundboard.VolumeControl do
@moduledoc """
Shared volume slider with preview support for upload/edit modals.
"""
use Phoenix.Component
attr :id, :string, default: nil
attr :value, :integer, required: true
attr :target, :string, required: true
attr :push_event, :string, default: "update_volume"
attr :label, :string, default: "Volume"
attr :input_name, :string, default: "volume"
attr :preview_disabled, :boolean, default: false
attr :preview_label, :string, default: "Preview"
attr :max_percent, :integer, default: 150
attr :rest, :global
def volume_control(assigns) do
~H"""
{@label}
0%
{@max_percent}%
{@value}%
{@preview_label}
"""
end
end
================================================
FILE: lib/soundboard_web/controllers/api/sound_controller.ex
================================================
defmodule SoundboardWeb.API.SoundController do
use SoundboardWeb, :controller
alias Soundboard.{Repo, Sound, Sounds}
def index(conn, _params) do
sounds =
Sound
|> Sound.with_tags()
|> Repo.all()
|> Enum.map(&format_sound(&1, conn.assigns[:current_user]))
json(conn, %{data: sounds})
end
def create(conn, params) do
with {:ok, user} <- require_upload_user(conn),
{:ok, sound} <- create_sound(user, params) do
conn
|> put_status(:created)
|> json(%{data: format_sound(sound, user)})
else
{:error, :forbidden_auth_state} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Uploads require a user API token"})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: changeset_errors(changeset)})
end
end
def play(conn, %{"id" => id}) do
case Repo.get(Sound, id) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Sound not found"})
sound ->
case require_play_user(conn) do
{:ok, user} ->
Soundboard.AudioPlayer.play_sound(sound.filename, user)
conn
|> put_status(:accepted)
|> json(%{
data: %{
status: "accepted",
message: "Playback request accepted for #{sound.filename}",
requested_by: user.username,
sound: %{id: sound.id, filename: sound.filename}
}
})
{:error, :forbidden_auth_state} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Playback requires a user API token"})
end
end
end
def stop(conn, _params) do
Soundboard.AudioPlayer.stop_sound()
conn
|> put_status(:accepted)
|> json(%{
data: %{
status: "accepted",
message: "Stop request accepted"
}
})
end
defp create_sound(user, params) do
user
|> Sounds.new_create_request(params)
|> Sounds.create_sound()
end
defp require_upload_user(conn) do
case conn.assigns[:current_user] do
%Soundboard.Accounts.User{} = user -> {:ok, user}
_ -> {:error, :forbidden_auth_state}
end
end
defp require_play_user(conn), do: require_upload_user(conn)
defp format_sound(sound, current_user) do
user_setting = find_user_setting(sound, current_user)
%{
id: sound.id,
filename: sound.filename,
source_type: sound.source_type,
url: sound.url,
volume: sound.volume,
description: sound.description,
tags: Enum.map(sound.tags || [], & &1.name),
is_join_sound: user_setting && user_setting.is_join_sound,
is_leave_sound: user_setting && user_setting.is_leave_sound,
inserted_at: sound.inserted_at,
updated_at: sound.updated_at
}
end
defp find_user_setting(_sound, nil), do: nil
defp find_user_setting(sound, user) do
settings =
if Ecto.assoc_loaded?(sound.user_sound_settings) do
sound.user_sound_settings
else
sound
|> Repo.preload(:user_sound_settings)
|> Map.get(:user_sound_settings)
end
Enum.find(settings, &(&1.user_id == user.id))
end
defp changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts
|> Keyword.get(String.to_existing_atom(key), key)
|> to_string()
end)
end)
end
end
================================================
FILE: lib/soundboard_web/controllers/auth_controller.ex
================================================
defmodule SoundboardWeb.AuthController do
use SoundboardWeb, :controller
plug Ueberauth
alias Soundboard.Accounts.User
alias Soundboard.Discord.RoleChecker
alias Soundboard.Repo
def request(conn, %{"provider" => "discord"} = _params) do
conn
|> put_session(:session_id, System.unique_integer())
|> configure_session(renew: true)
end
def request(conn, _params) do
conn
|> put_status(:not_found)
|> text("Unsupported auth provider")
end
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
if RoleChecker.authorized?(auth.uid) do
user_params = %{
discord_id: auth.uid,
username: auth.info.nickname || auth.info.name,
avatar: auth.info.image
}
case find_or_create_user(user_params) do
{:ok, user} ->
conn
|> put_session(:user_id, user.id)
|> put_session(:roles_verified_at, System.system_time(:second))
|> redirect(to: "/")
{:error, _reason} ->
conn
|> put_flash(:error, "Error signing in")
|> redirect(to: "/")
end
else
conn
|> put_flash(:error, "Error signing in")
|> redirect(to: "/")
end
end
def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do
conn
|> put_flash(:error, "Failed to authenticate")
|> redirect(to: "/")
end
defp find_or_create_user(%{discord_id: discord_id} = params) do
case Repo.get_by(User, discord_id: discord_id) do
nil ->
%User{}
|> User.changeset(params)
|> Repo.insert()
user ->
{:ok, user}
end
end
def logout(conn, _params) do
conn
|> clear_session()
|> redirect(to: "/")
end
def debug_session(conn, _params) do
json(conn, %{
session: %{
session_id: get_session(conn, :session_id),
user_id: get_session(conn, :user_id)
}
})
end
end
================================================
FILE: lib/soundboard_web/controllers/error_html.ex
================================================
defmodule SoundboardWeb.ErrorHTML do
@moduledoc """
Renders fallback HTML error messages.
"""
use SoundboardWeb, :html
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
================================================
FILE: lib/soundboard_web/controllers/error_json.ex
================================================
defmodule SoundboardWeb.ErrorJSON do
@moduledoc """
Renders fallback JSON error payloads.
"""
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
================================================
FILE: lib/soundboard_web/controllers/upload_controller.ex
================================================
defmodule SoundboardWeb.UploadController do
use SoundboardWeb, :controller
alias Soundboard.UploadsPath
def show(conn, %{"path" => path}) do
case UploadsPath.safe_joined_path(path) do
{:ok, file_path} ->
if File.regular?(file_path) do
send_file(conn, 200, file_path)
else
send_resp(conn, 404, "File not found")
end
:error ->
send_resp(conn, 404, "File not found")
end
end
end
================================================
FILE: lib/soundboard_web/endpoint.ex
================================================
defmodule SoundboardWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :soundboard
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_soundboard_key",
signing_salt: "dxNUerVp",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :soundboard,
gzip: false,
only: SoundboardWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :soundboard
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
length: 30_000_000,
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug SoundboardWeb.Router
end
================================================
FILE: lib/soundboard_web/gettext.ex
================================================
defmodule SoundboardWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
that you can use in your application. To use this Gettext backend module,
call `use Gettext` and pass it as an option:
use Gettext, backend: SoundboardWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext.Backend, otp_app: :soundboard
end
================================================
FILE: lib/soundboard_web/live/favorites_live.ex
================================================
defmodule SoundboardWeb.FavoritesLive do
use SoundboardWeb, :live_view
use SoundboardWeb.Live.Support.PresenceLive
alias Soundboard.{Favorites, PubSubTopics}
alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}
import FlashHelpers, only: [flash_sound_played: 2, clear_flash_after_timeout: 1]
require Logger
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
PubSubTopics.subscribe_files()
PubSubTopics.subscribe_playback()
end
socket =
socket
|> mount_presence(session)
|> assign(:current_path, "/favorites")
|> assign(:current_user, get_user_from_session(session))
|> assign(:max_favorites, Favorites.max_favorites())
{:ok, assign_favorites_state(socket, socket.assigns[:current_user])}
end
@impl true
def handle_event("play", %{"name" => filename}, socket) do
SoundPlayback.play(socket, filename)
end
@impl true
def handle_event("toggle_favorite", %{"sound-id" => sound_id}, socket) do
case socket.assigns.current_user do
nil ->
{:noreply, put_flash(socket, :error, "You must be logged in to favorite sounds")}
user ->
case Favorites.toggle_favorite(user.id, sound_id) do
{:ok, _favorite} ->
{:noreply,
socket
|> assign_favorites_state(user)
|> put_flash(:info, "Favorites updated!")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, Favorites.error_message(reason))}
end
end
end
@impl true
def handle_info({:sound_played, %{filename: _, played_by: _} = event}, socket) do
{:noreply, flash_sound_played(socket, event)}
end
@impl true
def handle_info({:sound_played, filename}, socket) when is_binary(filename) do
username =
case SoundPlayback.current_username(socket) do
{:ok, current_username} -> current_username
:error -> "Someone"
end
{:noreply,
socket
|> put_flash(:info, "#{username} played #{filename}")
|> clear_flash_after_timeout()}
end
@impl true
def handle_info({:error, message}, socket) do
{:noreply,
socket
|> put_flash(:error, message)
|> clear_flash_after_timeout()}
end
@impl true
def handle_info({:files_updated}, socket) do
{:noreply, assign_favorites_state(socket, socket.assigns[:current_user])}
end
@impl true
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
@impl true
def handle_info({:stats_updated}, socket) do
{:noreply, assign_favorites_state(socket, socket.assigns[:current_user])}
end
defp assign_favorites_state(socket, nil) do
assign(socket, favorites: [], sounds_with_tags: [])
end
defp assign_favorites_state(socket, user) do
favorites = Favorites.list_favorites(user.id)
assign(socket,
favorites: favorites,
sounds_with_tags: Favorites.list_favorite_sounds_with_tags(user.id)
)
end
end
================================================
FILE: lib/soundboard_web/live/favorites_live.html.heex
================================================
Favorites
{length(@favorites)}/{@max_favorites} favorites
<%= if @current_user do %>
<%= if @sounds_with_tags == [] do %>
😢
You currently have no favorites
Click the heart icon on any sound to add it to your favorites
<% else %>
<%= for sound <- @sounds_with_tags do %>
{display_name(sound.filename)}
<%= if sound.tags != [] do %>
<%= for tag <- sound.tags do %>
{tag.name}
<% end %>
<% end %>
<%= if sound.id in @favorites do %>
<.icon name="hero-heart-solid" class="w-4 h-4 text-red-500" />
<% else %>
<.icon name="hero-heart" class="w-4 h-4" />
<% end %>
<% end %>
<% end %>
<% else %>
Please log in to manage your favorites
<% end %>
================================================
FILE: lib/soundboard_web/live/settings_live.ex
================================================
defmodule SoundboardWeb.SettingsLive do
use SoundboardWeb, :live_view
use SoundboardWeb.Live.Support.PresenceLive
alias Soundboard.Accounts.ApiTokens
alias Soundboard.PublicURL
@impl true
def mount(_params, session, socket) do
socket =
socket
|> mount_presence(session)
|> assign(:current_path, "/settings")
|> assign(:current_user, get_user_from_session(session))
|> assign(:tokens, [])
|> assign(:new_token, nil)
|> assign(:base_url, PublicURL.current())
{:ok, load_tokens(socket)}
end
@impl true
def handle_params(_params, uri, socket) do
{:noreply, assign(socket, :base_url, PublicURL.from_uri_or_current(uri))}
end
@impl true
def handle_event(
"create_token",
%{"label" => label},
%{assigns: %{current_user: user}} = socket
) do
case ApiTokens.generate_token(user, %{label: String.trim(label)}) do
{:ok, raw, _token} ->
{:noreply,
socket
|> assign(:new_token, raw)
|> load_tokens()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create token")}
end
end
@impl true
def handle_event("revoke_token", %{"id" => id}, %{assigns: %{current_user: user}} = socket) do
case ApiTokens.revoke_token(user, id) do
{:ok, _} -> {:noreply, socket |> load_tokens() |> put_flash(:info, "Token revoked")}
{:error, :forbidden} -> {:noreply, put_flash(socket, :error, "Not allowed")}
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "Token not found")}
{:error, _} -> {:noreply, put_flash(socket, :error, "Failed to revoke token")}
end
end
defp load_tokens(%{assigns: %{current_user: nil}} = socket), do: socket
defp load_tokens(%{assigns: %{current_user: user}} = socket) do
tokens = ApiTokens.list_tokens(user)
example =
socket.assigns[:new_token] ||
case tokens do
[%{token: tok} | _] when is_binary(tok) -> tok
_ -> nil
end
socket
|> assign(:tokens, tokens)
|> assign(:example_token, example)
end
@impl true
def render(assigns) do
~H"""
Settings
Label
Token
Created
Last Used
<%= for token <- @tokens do %>
{token.label || "(no label)"}
{format_dt(token.inserted_at)}
{format_dt(token.last_used_at) || "—"}
Revoke
<% end %>
How to call the API
Include your token in the Authorization header:
Authorization: Bearer {@example_token || ""}
List sounds
")}\" #{@base_url}/api/sounds"}
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"
>
Copy
curl -H \"Authorization: Bearer {(@example_token || "")}\" {@base_url}/api/sounds
Upload endpoint: POST /api/sounds. Required fields:
name
plus either file
(local multipart)
or url
(source_type=url). Optional: tags,
volume
(0-150), is_join_sound, is_leave_sound.
Upload local file (multipart/form-data)
")}\" -F \"source_type=local\" -F \"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"}
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"
>
Copy
curl -X POST \
-H "Authorization: Bearer {(@example_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
Upload from URL (JSON)
")}\" -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"}
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"
>
Copy
curl -X POST \
-H "Authorization: Bearer {(@example_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
Play a sound by ID
")}\" #{@base_url}/api/sounds//play"}
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"
>
Copy
curl -X POST -H \"Authorization: Bearer {(@example_token || "")}\" {@base_url}/api/sounds/<SOUND_ID>/play
Stop all sounds
")}\" #{@base_url}/api/sounds/stop"}
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"
>
Copy
curl -X POST -H \"Authorization: Bearer {(@example_token || "")}\" {@base_url}/api/sounds/stop
"""
end
defp format_dt(nil), do: nil
defp format_dt(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
end
================================================
FILE: lib/soundboard_web/live/soundboard_live/edit_flow.ex
================================================
defmodule SoundboardWeb.Live.SoundboardLive.EditFlow do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias Soundboard.{Sound, Sounds, Volume}
alias SoundboardWeb.Live.Support.{LiveTags, TagForm}
@tag_form %{input_key: :tag_input, suggestions_key: :tag_suggestions}
defmodule State do
@moduledoc false
defstruct show_modal: false,
current_sound: nil,
tag_input: "",
tag_suggestions: [],
show_delete_confirm: false,
edit_name_error: nil,
current_user_id: nil
@type t :: %__MODULE__{
show_modal: boolean(),
current_sound: Sound.t() | nil,
tag_input: String.t(),
tag_suggestions: list(),
show_delete_confirm: boolean(),
edit_name_error: String.t() | nil,
current_user_id: integer() | nil
}
end
def assign_defaults(socket), do: put_state(socket, default_state())
def validate_sound(socket, %{"_target" => ["filename"]} = params) do
current_sound_id = params["sound_id"]
error =
case Sounds.fetch_filename_extension(current_sound_id) do
{:ok, extension} ->
filename = String.trim(params["filename"] || "") <> extension
if Sounds.filename_taken_excluding?(filename, current_sound_id) do
"A sound with that name already exists"
end
:error ->
nil
end
{:noreply, update_state(socket, &%{&1 | edit_name_error: error})}
end
def validate_sound(socket, _params), do: {:noreply, socket}
def open_modal(socket, id) do
sound = Sounds.get_sound!(id)
{:noreply,
socket
|> update_state(fn state ->
%{state | current_sound: sound, show_modal: true, edit_name_error: nil}
end)}
end
def close_modal(socket), do: put_state(socket, default_state())
def add_tag(socket, key, value) do
edit = state(socket)
TagForm.handle_key(socket, key, value, current_tags(edit), &append_sound_tag/3, @tag_form)
end
def remove_tag(socket, tag_name) do
edit = state(socket)
tags = Enum.reject(current_tags(edit), &(&1.name == tag_name))
{:ok, updated_sound} = LiveTags.update_sound_tags(edit.current_sound, tags)
LiveTags.broadcast_update()
{:noreply,
socket
|> update_state(&%{&1 | current_sound: updated_sound})
|> assign(:uploaded_files, Sounds.list_detailed())}
end
def select_tag_suggestion(socket, tag_name), do: select_tag(socket, tag_name)
def update_tag_input(socket, value), do: TagForm.update_input(socket, value, @tag_form)
def select_tag(socket, tag_name) do
edit = state(socket)
TagForm.select_tag(socket, tag_name, current_tags(edit), &append_sound_tag/3, @tag_form)
end
def save_sound(socket, params) do
edit = state(socket)
case Sounds.update_sound(edit.current_sound, edit.current_user_id, params) do
{:ok, _updated_sound} ->
LiveTags.broadcast_update()
{:noreply,
socket
|> Phoenix.LiveView.put_flash(:info, "Sound updated successfully")
|> close_modal()
|> assign(:uploaded_files, Sounds.list_detailed())}
{:error, error} ->
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
"Error updating sound: #{error_message(error)}"
)}
end
end
def show_delete_confirm(socket) do
{:noreply, update_state(socket, &%{&1 | show_delete_confirm: true})}
end
def hide_delete_confirm(socket) do
{:noreply, update_state(socket, &%{&1 | show_delete_confirm: false})}
end
def delete_sound(socket) do
edit = state(socket)
case Sounds.delete_sound(edit.current_sound, edit.current_user_id) do
:ok ->
{:noreply,
socket
|> close_modal()
|> assign(:uploaded_files, Sounds.list_detailed())
|> Phoenix.LiveView.put_flash(:info, "Sound deleted successfully")}
{:error, :forbidden} ->
{:noreply,
socket
|> update_state(&%{&1 | show_delete_confirm: false})
|> Phoenix.LiveView.put_flash(:error, "You can only delete your own sounds")}
{:error, _changeset} ->
{:noreply,
socket
|> update_state(&%{&1 | show_delete_confirm: false})
|> Phoenix.LiveView.put_flash(:error, "Failed to delete sound")}
end
end
def update_volume(socket, volume) do
edit = state(socket)
case edit.current_sound do
nil ->
{:noreply, socket}
sound ->
default_percent = Volume.decimal_to_percent(sound.volume)
updated_sound =
Map.put(sound, :volume, Volume.percent_to_decimal(volume, default_percent))
{:noreply, update_state(socket, &%{&1 | current_sound: updated_sound})}
end
end
defp error_message(%Ecto.Changeset{} = changeset) do
Enum.map_join(changeset.errors, ", ", fn {field, {msg, _opts}} ->
"#{field} #{msg}"
end)
end
defp error_message(_), do: "Failed to update sound"
defp append_sound_tag(socket, tag, current_tags) do
edit = state(socket)
case LiveTags.update_sound_tags(edit.current_sound, [tag | current_tags]) do
{:ok, updated_sound} ->
LiveTags.broadcast_update()
{:ok, update_state(socket, &%{&1 | current_sound: updated_sound})}
{:error, _} ->
{:error, "Failed to add tag"}
end
end
defp current_tags(%State{current_sound: %{tags: tags}}) when is_list(tags), do: tags
defp current_tags(_state), do: []
defp default_state, do: %State{}
defp state(socket) do
%State{
show_modal: Map.get(socket.assigns, :show_modal, false),
current_sound: Map.get(socket.assigns, :current_sound),
tag_input: Map.get(socket.assigns, :tag_input, ""),
tag_suggestions: Map.get(socket.assigns, :tag_suggestions, []),
show_delete_confirm: Map.get(socket.assigns, :show_delete_confirm, false),
edit_name_error: Map.get(socket.assigns, :edit_name_error),
current_user_id: socket.assigns[:current_user] && socket.assigns.current_user.id
}
end
defp update_state(socket, fun) when is_function(fun, 1) do
socket
|> state()
|> fun.()
|> then(&put_state(socket, &1))
end
defp put_state(socket, %State{} = state) do
socket
|> assign(:edit_state, state)
|> assign(:show_modal, state.show_modal)
|> assign(:current_sound, state.current_sound)
|> assign(:tag_input, state.tag_input)
|> assign(:tag_suggestions, state.tag_suggestions)
|> assign(:show_delete_confirm, state.show_delete_confirm)
|> assign(:edit_name_error, state.edit_name_error)
end
end
================================================
FILE: lib/soundboard_web/live/soundboard_live/upload_flow.ex
================================================
defmodule SoundboardWeb.Live.SoundboardLive.UploadFlow do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias Soundboard.{Sounds, Volume}
alias SoundboardWeb.Live.Support.TagForm
@tag_form %{input_key: :upload_tag_input, suggestions_key: :upload_tag_suggestions}
defmodule State do
@moduledoc false
defstruct show_upload_modal: false,
source_type: "local",
upload_name: "",
url: "",
upload_tags: [],
upload_tag_input: "",
upload_tag_suggestions: [],
is_join_sound: false,
is_leave_sound: false,
upload_error: nil,
upload_volume: 100,
current_user: nil,
audio_entries: [],
current_upload: nil
@type t :: %__MODULE__{
show_upload_modal: boolean(),
source_type: String.t(),
upload_name: String.t(),
url: String.t(),
upload_tags: list(),
upload_tag_input: String.t(),
upload_tag_suggestions: list(),
is_join_sound: boolean(),
is_leave_sound: boolean(),
upload_error: String.t() | nil,
upload_volume: number(),
current_user: term(),
audio_entries: list(),
current_upload: map() | nil
}
end
def assign_defaults(socket), do: put_state(socket, default_state())
def change_source_type(socket, source_type) do
{:noreply, update_state(socket, &%{&1 | source_type: source_type})}
end
def save(socket, params, consume_uploaded_entries_fn) do
upload = state(socket)
case upload.source_type do
"url" ->
case Sounds.create_sound(build_request(upload, params)) do
{:ok, _sound} ->
{:noreply,
socket
|> close_modal()
|> assign(:uploaded_files, Sounds.list_detailed())
|> Phoenix.LiveView.put_flash(:info, "Sound added successfully")}
{:error, changeset} ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, Sounds.create_error_message(changeset))}
end
_ ->
results =
consume_uploaded_entries_fn.(socket, :audio, fn meta, entry ->
request =
upload
|> build_request(params)
|> Sounds.put_request_upload(%{path: meta.path, filename: entry.client_name})
{:ok, Sounds.create_sound(request)}
end)
handle_save_results(socket, results)
end
end
def validate(socket, params) do
upload = state(socket)
socket = validate_existing_entries(socket, upload)
upload = state(socket)
params = normalize_params(upload, params)
case validate_request(upload, params) do
:ok ->
{:noreply, assign_params(socket, upload, params, nil)}
{:error, changeset} ->
{:noreply, assign_params(socket, upload, params, Sounds.create_error_message(changeset))}
end
end
def show_modal(socket) do
{:noreply,
socket
|> reset_state()
|> update_state(&%{&1 | show_upload_modal: true})}
end
def hide_modal(socket), do: {:noreply, close_modal(socket)}
def close_modal(socket) do
socket
|> reset_state()
|> update_state(&%{&1 | show_upload_modal: false})
end
def add_tag(socket, key, value) do
upload = state(socket)
TagForm.handle_key(socket, key, value, upload.upload_tags, &append_upload_tag/3, @tag_form)
end
def remove_tag(socket, tag_name) do
{:noreply,
update_state(socket, fn upload ->
%{upload | upload_tags: Enum.reject(upload.upload_tags, &(&1.name == tag_name))}
end)}
end
def select_tag_suggestion(socket, tag_name), do: select_tag(socket, tag_name)
def update_tag_input(socket, value), do: TagForm.update_input(socket, value, @tag_form)
def select_tag(socket, tag_name) do
upload = state(socket)
TagForm.select_tag(socket, tag_name, upload.upload_tags, &append_upload_tag/3, @tag_form)
end
def toggle_join_sound(socket) do
{:noreply, update_state(socket, &%{&1 | is_join_sound: !&1.is_join_sound})}
end
def toggle_leave_sound(socket) do
{:noreply, update_state(socket, &%{&1 | is_leave_sound: !&1.is_leave_sound})}
end
def update_volume(socket, volume) do
{:noreply,
update_state(socket, fn upload ->
%{upload | upload_volume: Volume.normalize_percent(volume, upload.upload_volume)}
end)}
end
defp append_upload_tag(socket, tag, current_tags) do
{:ok, update_state(socket, &%{&1 | upload_tags: [tag | current_tags]})}
end
defp reset_state(socket), do: put_state(socket, default_state())
defp normalize_params(upload, params) do
params
|> Map.put_new("source_type", upload.source_type)
|> Map.put_new("name", default_upload_name(upload, params))
|> Map.put_new("url", upload.url)
end
defp assign_params(socket, upload, params, error) do
update_state(socket, fn state ->
%{
state
| upload_error: error,
upload_name: params["name"] || upload.upload_name,
url: params["url"] || upload.url,
source_type: params["source_type"] || upload.source_type
}
end)
end
defp validate_existing_entries(socket, %State{audio_entries: []}), do: socket
defp validate_existing_entries(socket, %State{} = upload) do
case upload.audio_entries do
[entry | _] ->
case validate_audio(entry) do
{:ok, _} -> socket
{:error, error} -> Phoenix.LiveView.put_flash(socket, :error, error)
end
_ ->
socket
end
end
defp validate_audio(entry) do
case entry.client_type do
type when type in ~w(audio/mpeg audio/wav audio/ogg audio/x-m4a) -> {:ok, entry}
_ -> {:error, "Invalid file type"}
end
end
defp validate_request(upload, %{"source_type" => "url", "name" => name, "url" => url}) do
if blank?(name) and blank?(url) do
:ok
else
case Sounds.validate_create(build_request(upload, %{"name" => name, "url" => url})) do
{:ok, _params} -> :ok
{:error, changeset} -> {:error, changeset}
end
end
end
defp validate_request(upload, params) do
request =
upload
|> build_request(params)
|> Sounds.put_request_upload(upload.current_upload)
case Sounds.validate_create(request) do
{:ok, _params} -> :ok
{:error, changeset} -> {:error, changeset}
end
end
defp handle_save_results(socket, [{:ok, _sound}]) do
{:noreply,
socket
|> close_modal()
|> assign(:uploaded_files, Sounds.list_detailed())
|> Phoenix.LiveView.put_flash(:info, "Sound added successfully")}
end
defp handle_save_results(socket, [{:error, changeset}]) do
{:noreply, Phoenix.LiveView.put_flash(socket, :error, Sounds.create_error_message(changeset))}
end
defp handle_save_results(socket, []) do
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
Sounds.create_error_message(
%Ecto.Changeset{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:file, "Please select a file")
)
)}
end
defp handle_save_results(socket, _results) do
{:noreply, Phoenix.LiveView.put_flash(socket, :error, "Error saving file")}
end
defp build_request(%State{} = upload, params) do
Sounds.new_create_request(upload.current_user, %{
source_type: upload.source_type,
name: params["name"],
url: params["url"],
tags: upload.upload_tags,
volume: params["volume"],
default_volume_percent: upload.upload_volume,
is_join_sound: upload.is_join_sound,
is_leave_sound: upload.is_leave_sound
})
end
defp default_upload_name(upload, params) do
current_name = upload.upload_name
source_type = params["source_type"] || upload.source_type
url = params["url"] || upload.url
cond do
present?(current_name) -> current_name
source_type == "local" -> inferred_upload_name(upload.current_upload)
source_type == "url" -> inferred_url_name(url)
true -> ""
end
end
defp inferred_upload_name(%{filename: filename}) when is_binary(filename) do
filename
|> Path.basename()
|> Path.rootname()
end
defp inferred_upload_name(_), do: ""
defp inferred_url_name(url) when is_binary(url) do
url
|> URI.parse()
|> Map.get(:path, "")
|> Path.basename()
|> Path.rootname()
|> case do
"." -> ""
name -> name
end
end
defp inferred_url_name(_), do: ""
defp default_state, do: %State{}
defp state(socket) do
%State{
show_upload_modal: Map.get(socket.assigns, :show_upload_modal, false),
source_type: Map.get(socket.assigns, :source_type, "local"),
upload_name: Map.get(socket.assigns, :upload_name, ""),
url: Map.get(socket.assigns, :url, ""),
upload_tags: Map.get(socket.assigns, :upload_tags, []),
upload_tag_input: Map.get(socket.assigns, :upload_tag_input, ""),
upload_tag_suggestions: Map.get(socket.assigns, :upload_tag_suggestions, []),
is_join_sound: Map.get(socket.assigns, :is_join_sound, false),
is_leave_sound: Map.get(socket.assigns, :is_leave_sound, false),
upload_error: Map.get(socket.assigns, :upload_error),
upload_volume: Map.get(socket.assigns, :upload_volume, 100),
current_user: Map.get(socket.assigns, :current_user),
audio_entries: audio_entries(socket),
current_upload: current_upload(socket)
}
end
defp update_state(socket, fun) when is_function(fun, 1) do
socket
|> state()
|> fun.()
|> then(&put_state(socket, &1))
end
defp put_state(socket, %State{} = state) do
socket
|> assign(:upload_state, state)
|> assign(:show_upload_modal, state.show_upload_modal)
|> assign(:source_type, state.source_type)
|> assign(:upload_name, state.upload_name)
|> assign(:url, state.url)
|> assign(:upload_tags, state.upload_tags)
|> assign(:upload_tag_input, state.upload_tag_input)
|> assign(:upload_tag_suggestions, state.upload_tag_suggestions)
|> assign(:is_join_sound, state.is_join_sound)
|> assign(:is_leave_sound, state.is_leave_sound)
|> assign(:upload_error, state.upload_error)
|> assign(:upload_volume, state.upload_volume)
end
defp audio_entries(socket) do
socket.assigns
|> Map.get(:uploads, %{})
|> Map.get(:audio)
|> case do
%{entries: entries} when is_list(entries) -> entries
_ -> []
end
end
defp current_upload(socket) do
if get_in(socket.assigns, [:uploads, :audio]) do
case Phoenix.LiveView.uploaded_entries(socket, :audio) do
{[entry | _], _} -> %{filename: entry.client_name}
{_, [entry | _]} -> %{filename: entry.client_name}
_ -> nil
end
else
nil
end
end
defp blank?(value), do: value in [nil, ""]
defp present?(value), do: not blank?(value)
end
================================================
FILE: lib/soundboard_web/live/soundboard_live.ex
================================================
defmodule SoundboardWeb.SoundboardLive do
use SoundboardWeb, :live_view
use SoundboardWeb.Live.Support.PresenceLive
alias SoundboardWeb.Components.Soundboard.{DeleteModal, EditModal, UploadModal}
import EditModal
import DeleteModal
import UploadModal
import SoundboardWeb.Components.Soundboard.TagComponents, only: [tag_filter_button: 1]
alias Soundboard.{Favorites, PubSubTopics, Sounds}
alias SoundboardWeb.Live.SoundboardLive.{EditFlow, UploadFlow}
alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}
alias SoundboardWeb.Soundboard.SoundFilter
import SoundboardWeb.Live.Support.LiveTags, only: [all_tags: 1, tag_selected?: 2]
import SoundFilter, only: [filter_sounds: 3]
@impl true
def mount(_params, session, socket) do
socket =
if connected?(socket) do
PubSubTopics.subscribe_files()
PubSubTopics.subscribe_playback()
send(self(), :load_sound_files)
socket
else
socket
end
socket =
socket
|> mount_presence(session)
|> assign(:current_path, "/")
|> assign(:current_user, get_user_from_session(session))
|> assign_initial_state()
|> assign_favorites(get_user_from_session(session))
if socket.assigns.flash do
Process.send_after(self(), :clear_flash, 3000)
end
{:ok, socket}
end
defp assign_initial_state(socket) do
socket
|> assign(:uploaded_files, [])
|> assign(:loading_sounds, true)
|> assign(:search_query, "")
|> assign(:editing, nil)
|> assign(:selected_tags, [])
|> assign(:show_all_tags, false)
|> UploadFlow.assign_defaults()
|> EditFlow.assign_defaults()
|> allow_upload(:audio,
accept: ~w(audio/mpeg audio/wav audio/ogg audio/x-m4a),
max_entries: 1,
max_file_size: 25_000_000,
auto_upload: false,
progress: &handle_progress/3,
accept_errors: [
too_large: "File is too large (max 25MB)",
not_accepted: "Invalid file type. Please upload an MP3, WAV, OGG, or M4A file."
]
)
end
@impl true
def handle_event("validate", _params, socket), do: {:noreply, socket}
@impl true
def handle_event("change_source_type", %{"source_type" => source_type}, socket) do
UploadFlow.change_source_type(socket, source_type)
end
@impl true
def handle_event("validate_sound", params, socket) do
EditFlow.validate_sound(socket, params)
end
@impl true
def handle_event("toggle_tag_list", _params, socket) do
{:noreply, assign(socket, :show_all_tags, !socket.assigns.show_all_tags)}
end
@impl true
def handle_event("play", %{"name" => filename}, socket) do
SoundPlayback.play(socket, filename)
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
{:noreply, assign(socket, :search_query, query)}
end
@impl true
def handle_event("toggle_tag_filter", %{"tag" => tag_name}, socket) do
case Enum.find(all_tags(socket.assigns.uploaded_files), &(&1.name == tag_name)) do
nil ->
{:noreply, socket}
tag ->
current_tag = List.first(socket.assigns.selected_tags)
selected_tags = if current_tag && current_tag.id == tag.id, do: [], else: [tag]
{:noreply,
socket
|> assign(:selected_tags, selected_tags)
|> assign(:search_query, "")}
end
end
@impl true
def handle_event("clear_tag_filters", _, socket) do
{:noreply, assign(socket, :selected_tags, [])}
end
@impl true
def handle_event("edit", %{"id" => id}, socket) do
EditFlow.open_modal(socket, id)
end
@impl true
def handle_event("save_upload", params, socket) do
UploadFlow.save(socket, params, &Phoenix.LiveView.consume_uploaded_entries/3)
end
@impl true
def handle_event("validate_upload", params, socket) do
UploadFlow.validate(socket, params)
end
@impl true
def handle_event("show_upload_modal", _params, socket) do
UploadFlow.show_modal(socket)
end
@impl true
def handle_event("hide_upload_modal", _params, socket) do
UploadFlow.hide_modal(socket)
end
@impl true
def handle_event("add_upload_tag", %{"key" => key, "value" => value}, socket) do
UploadFlow.add_tag(socket, key, value)
end
@impl true
def handle_event("remove_upload_tag", %{"tag" => tag_name}, socket) do
UploadFlow.remove_tag(socket, tag_name)
end
@impl true
def handle_event("select_upload_tag_suggestion", %{"tag" => tag_name}, socket) do
UploadFlow.select_tag_suggestion(socket, tag_name)
end
@impl true
def handle_event("upload_tag_input", %{"key" => _key, "value" => value}, socket) do
UploadFlow.update_tag_input(socket, value)
end
@impl true
def handle_event("add_tag", %{"key" => key, "value" => value}, socket) do
EditFlow.add_tag(socket, key, value)
end
@impl true
def handle_event("remove_tag", %{"tag" => tag_name}, socket) do
EditFlow.remove_tag(socket, tag_name)
end
@impl true
def handle_event("select_tag_suggestion", %{"tag" => tag_name}, socket) do
EditFlow.select_tag_suggestion(socket, tag_name)
end
@impl true
def handle_event("tag_input", %{"key" => _key, "value" => value}, socket) do
EditFlow.update_tag_input(socket, value)
end
@impl true
def handle_event("select_tag", %{"tag" => tag_name}, socket) do
EditFlow.select_tag(socket, tag_name)
end
@impl true
def handle_event("save_sound", params, socket) do
EditFlow.save_sound(socket, params)
end
@impl true
def handle_event("close_upload_modal", _params, socket) do
UploadFlow.hide_modal(socket)
end
@impl true
def handle_event("close_modal", _params, socket) do
{:noreply,
socket
|> UploadFlow.close_modal()
|> EditFlow.close_modal()}
end
@impl true
def handle_event("close_modal_key", %{"key" => "Escape"}, socket) do
edit_open = socket.assigns[:edit_state] && socket.assigns.edit_state.show_modal
upload_open = socket.assigns[:upload_state] && socket.assigns.upload_state.show_upload_modal
if edit_open || upload_open do
handle_event("close_modal", %{}, socket)
else
{:noreply, socket}
end
end
@impl true
def handle_event("select_upload_tag", %{"tag" => tag_name}, socket) do
UploadFlow.select_tag(socket, tag_name)
end
@impl true
def handle_event("toggle_favorite", %{"sound-id" => sound_id}, socket) do
case socket.assigns.current_user do
nil ->
{:noreply, put_flash(socket, :error, "You must be logged in to favorite sounds")}
user ->
case Favorites.toggle_favorite(user.id, sound_id) do
{:ok, _favorite} ->
{:noreply,
socket
|> assign_favorites(user)
|> put_flash(:info, "Favorites updated!")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, Favorites.error_message(reason))}
end
end
end
@impl true
def handle_event("show_delete_confirm", _params, socket) do
EditFlow.show_delete_confirm(socket)
end
@impl true
def handle_event("hide_delete_confirm", _params, socket) do
EditFlow.hide_delete_confirm(socket)
end
@impl true
def handle_event("delete_sound", _params, socket) do
EditFlow.delete_sound(socket)
end
@impl true
def handle_event("toggle_join_sound", _params, socket) do
UploadFlow.toggle_join_sound(socket)
end
@impl true
def handle_event("toggle_leave_sound", _params, socket) do
UploadFlow.toggle_leave_sound(socket)
end
@impl true
def handle_event("update_volume", %{"volume" => volume, "target" => "edit"}, socket) do
EditFlow.update_volume(socket, volume)
end
@impl true
def handle_event("update_volume", %{"volume" => volume, "target" => "upload"}, socket) do
UploadFlow.update_volume(socket, volume)
end
@impl true
def handle_event("update_volume", _params, socket), do: {:noreply, socket}
@impl true
def handle_event("play_random", _params, socket) do
filtered_sounds =
filter_sounds(
socket.assigns.uploaded_files,
socket.assigns.search_query,
socket.assigns.selected_tags
)
case get_random_sound(filtered_sounds) do
nil ->
{:noreply, socket}
sound ->
SoundPlayback.play(socket, sound.filename)
end
end
@impl true
def handle_event("stop_sound", _params, socket) do
# Stop browser-based sounds
socket = push_event(socket, "stop-all-sounds", %{})
# Stop Discord bot sounds if user is logged in
if socket.assigns.current_user do
Soundboard.AudioPlayer.stop_sound()
end
{:noreply, socket}
end
@impl true
def handle_info({:error, message}, socket) do
{:noreply, put_flash(socket, :error, message)}
end
@impl true
def handle_info({:sound_played, %{filename: _, played_by: _} = event}, socket) do
{:noreply, FlashHelpers.flash_sound_played(socket, event)}
end
@impl true
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
@impl true
def handle_info({:files_updated}, socket) do
{:noreply, load_sound_files(socket)}
end
@impl true
def handle_info(:load_sound_files, socket) do
{:noreply,
socket
|> load_sound_files()
|> assign(:loading_sounds, false)}
end
defp assign_favorites(socket, nil), do: assign(socket, :favorites, [])
defp assign_favorites(socket, user) do
favorites = Favorites.list_favorites(user.id)
assign(socket, :favorites, favorites)
end
defp load_sound_files(socket) do
assign(socket, :uploaded_files, Sounds.list_detailed())
end
defp get_random_sound([]), do: nil
defp get_random_sound(sounds) do
Enum.random(sounds)
end
defp handle_progress(:audio, _entry, socket) do
{:noreply, socket}
end
end
================================================
FILE: lib/soundboard_web/live/soundboard_live.html.heex
================================================
Sounds
Add Sound
Play Random
Stop All
<%!-- Search Bar --%>
<% tags = all_tags(@uploaded_files) %>
<% limited_tags =
if @show_all_tags do
tags
else
Enum.take(tags, 12)
end %>
<%= for tag <- limited_tags do %>
<.tag_filter_button
tag={tag}
selected_tags={@selected_tags}
uploaded_files={@uploaded_files}
/>
<% end %>
<%= if length(tags) > 12 do %>
{if @show_all_tags, do: "Show fewer tags", else: "Show more tags"}
<% end %>
<%= for tag <- tags do %>
<.tag_filter_button
tag={tag}
selected_tags={@selected_tags}
uploaded_files={@uploaded_files}
/>
<% end %>
Total Sounds: {length(@uploaded_files)}
<%!-- Sound Grid --%>
<%= if @loading_sounds do %>
<% else %>
<%= for sound <- filter_sounds(@uploaded_files, @search_query, @selected_tags) do %>
<%!-- Make the whole card clickable --%>
<%!-- Content container with higher z-index --%>
<%!-- Sound title and uploader info --%>
{display_name(sound.filename)}
<%= if sound.user && sound.user.username do %>
Uploaded by {sound.user.username}
<% end %>
<%!-- Bottom Section with Tags and Icons --%>
<%!-- Tags on the left --%>
<%= if sound.tags != [] do %>
<%= for tag <- sound.tags do %>
{tag.name}
<% end %>
<% end %>
<%!-- Icons on the right --%>
<%= if @current_user do %>
<%= if sound.id in @favorites do %>
<.icon name="hero-heart-solid" class="w-4 h-4 text-red-500" />
<% else %>
<.icon name="hero-heart" class="w-4 h-4" />
<% end %>
<% end %>
<% end %>
<% end %>
<%= if @show_modal do %>
<.edit_modal
flash={@flash}
edit_name_error={@edit_name_error}
current_user={@current_user}
current_sound={@current_sound}
tag_input={@tag_input}
tag_suggestions={@tag_suggestions}
/>
<%= if @show_delete_confirm do %>
<.delete_modal {assigns} />
<% end %>
<% end %>
<%= if @show_upload_modal do %>
<.upload_modal {assigns} />
<% end %>
================================================
FILE: lib/soundboard_web/live/stats_live.ex
================================================
defmodule SoundboardWeb.StatsLive do
use SoundboardWeb, :live_view
use SoundboardWeb.Live.Support.PresenceLive
alias SoundboardWeb.PresenceHandler
import Phoenix.Component
import SoundboardWeb.SoundHelpers
alias Soundboard.{Accounts, Favorites, PubSubTopics, Sounds, Stats}
alias SoundboardWeb.Live.Support.{FlashHelpers, SoundPlayback}
import FlashHelpers, only: [clear_flash_after_timeout: 1]
require Logger
@recent_limit 5
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
:timer.send_interval(60 * 60 * 1000, self(), :check_week_rollover)
PubSubTopics.subscribe_playback()
PubSubTopics.subscribe_stats()
end
current_week = get_week_range()
{:ok,
socket
|> mount_presence(session)
|> assign(:current_path, "/stats")
|> assign(:current_user, get_user_from_session(session))
|> assign(:force_update, 0)
|> assign(:selected_week, current_week)
|> assign(:current_week, current_week)
|> stream_configure(:recent_plays, dom_id: &recent_play_dom_id/1)
|> stream(:recent_plays, [])
|> assign_stats()}
end
@impl true
def handle_info({:sound_played, %{filename: filename, played_by: username}}, socket) do
recent_plays = recent_plays()
{:noreply,
socket
|> stream(:recent_plays, recent_plays, reset: true)
|> put_flash(:info, "#{username} played #{display_name(filename)}")
|> clear_flash_after_timeout()}
end
@impl true
def handle_info({:stats_updated}, socket) do
{:noreply, assign_stats(socket)}
end
@impl true
def handle_info({:error, message}, socket) do
{:noreply,
socket
|> put_flash(:error, message)
|> clear_flash_after_timeout()}
end
@impl true
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
defp assign_stats(socket) do
{start_date, end_date} = socket.assigns.selected_week
top_users = Stats.get_top_users(start_date, end_date, limit: @recent_limit)
top_sounds = Stats.get_top_sounds(start_date, end_date, limit: @recent_limit)
recent_plays = recent_plays()
recent_uploads = Sounds.get_recent_uploads(limit: @recent_limit)
favorites = get_favorites(socket.assigns.current_user)
sound_ids_by_filename = load_sound_ids_by_filename(top_sounds, recent_plays, recent_uploads)
avatars_by_username = load_avatars_by_username(top_users, recent_plays, recent_uploads)
socket
|> assign(:top_users, top_users)
|> assign(:top_sounds, top_sounds)
|> stream(:recent_plays, recent_plays, reset: true)
|> assign(:recent_uploads, recent_uploads)
|> assign(:favorites, favorites)
|> assign(:sound_ids_by_filename, sound_ids_by_filename)
|> assign(:avatars_by_username, avatars_by_username)
end
defp get_favorites(nil), do: []
defp get_favorites(user), do: Favorites.list_favorites(user.id)
defp format_timestamp(timestamp) do
timestamp
|> DateTime.from_naive!("Etc/UTC")
|> Calendar.strftime("%b %d, %I:%M %p UTC")
end
defp get_week_range(date \\ Date.utc_today()) do
days_since_monday = Date.day_of_week(date, :monday)
start_date = Date.add(date, -days_since_monday + 1)
end_date = Date.add(start_date, 6)
{start_date, end_date}
end
defp format_date_range({start_date, end_date}) do
"#{Calendar.strftime(start_date, "%b %d")} - #{Calendar.strftime(end_date, "%b %d, %Y")}"
end
defp date_input_value({start_date, _end_date}) do
Date.to_iso8601(start_date)
end
defp parse_week_input(nil), do: :error
defp parse_week_input(""), do: :error
defp parse_week_input(week_value) do
case Date.from_iso8601(week_value) do
{:ok, date} -> {:ok, get_week_range(date)}
_ -> :error
end
end
@impl true
def render(assigns) do
~H"""
Stats
<.icon name="hero-chevron-left-solid" class="h-5 w-5" />
{format_date_range(@selected_week)}
<.icon name="hero-chevron-right-solid" class="h-5 w-5" />
Top Users
<%= for {username, count} <- @top_users do %>
{username}
{count} plays
<% end %>
Top Sounds
<%= for {sound_name, count} <- @top_sounds do %>
{display_name(sound_name)}
{count} plays
<%= if favorite?(@favorites, sound_name, @sound_ids_by_filename) do %>
<.icon name="hero-heart-solid" class="h-5 w-5 text-red-500" />
<% else %>
<.icon name="hero-heart" class="h-5 w-5" />
<% end %>
<% end %>
Recent Plays
<%= for {dom_id, play} <- @streams.recent_plays do %>
{display_name(play.filename)}
{play.username}
{format_timestamp(play.timestamp)}
<%= if favorite?(@favorites, play.filename, @sound_ids_by_filename) do %>
<.icon name="hero-heart-solid" class="h-5 w-5 text-red-500" />
<% else %>
<.icon name="hero-heart" class="h-5 w-5" />
<% end %>
<% end %>
Recently Uploaded
<%= for {sound_name, username, timestamp} <- @recent_uploads do %>
{display_name(sound_name)}
{username}
{format_timestamp(timestamp)}
<%= if favorite?(@favorites, sound_name, @sound_ids_by_filename) do %>
<.icon name="hero-heart-solid" class="h-5 w-5 text-red-500" />
<% else %>
<.icon name="hero-heart" class="h-5 w-5" />
<% end %>
<% end %>
"""
end
defp get_user_color_from_presence(username, presences) do
presences
|> Enum.find_value(fn {_id, presence} ->
meta = List.first(presence.metas)
if get_in(meta, [:user, :username]) == username do
get_in(meta, [:user, :color]) ||
PresenceHandler.get_user_color(username)
end
end) || PresenceHandler.get_user_color(username)
end
defp handle_favorite_toggle(socket, user, sound_name) do
case Sounds.fetch_sound_id(sound_name) do
{:ok, sound_id} -> update_favorite(socket, user, sound_id)
:error -> {:noreply, put_flash(socket, :error, "Sound not found")}
end
end
defp update_favorite(socket, user, sound_id) do
case Favorites.toggle_favorite(user.id, sound_id) do
{:ok, _favorite} ->
updated_favorites = Favorites.list_favorites(user.id)
recent_plays = recent_plays()
{:noreply,
socket
|> assign(:favorites, updated_favorites)
|> stream(:recent_plays, recent_plays, reset: true)
|> put_flash(:info, "Favorites updated!")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, Favorites.error_message(reason))}
end
end
defp recent_plays do
Stats.get_recent_plays(limit: @recent_limit)
|> Enum.map(&map_recent_play/1)
end
defp map_recent_play({id, filename, username, timestamp}) do
%{
id: id,
filename: filename,
username: username,
timestamp: timestamp
}
end
defp load_sound_ids_by_filename(top_sounds, recent_plays, recent_uploads) do
filenames =
top_sounds
|> Enum.map(fn {filename, _count} -> filename end)
|> Kernel.++(Enum.map(recent_plays, & &1.filename))
|> Kernel.++(Enum.map(recent_uploads, fn {filename, _username, _timestamp} -> filename end))
|> Enum.uniq()
case filenames do
[] ->
%{}
_ ->
Sounds.ids_by_filename(filenames)
end
end
defp load_avatars_by_username(top_users, recent_plays, recent_uploads) do
usernames =
top_users
|> Enum.map(fn {username, _count} -> username end)
|> Kernel.++(Enum.map(recent_plays, & &1.username))
|> Kernel.++(Enum.map(recent_uploads, fn {_filename, username, _timestamp} -> username end))
|> Enum.uniq()
case usernames do
[] ->
%{}
_ ->
Accounts.avatars_by_usernames(usernames)
end
end
defp recent_play_dom_id(play) do
base = slugify(play.filename)
"recent-play-#{base}-#{play.id}"
end
@impl true
def handle_event("play_sound", %{"sound" => sound_name}, socket) do
SoundPlayback.play(socket, sound_name)
end
@impl true
def handle_event("toggle_favorite", %{"sound" => sound_name}, socket) do
case socket.assigns.current_user do
nil ->
{:noreply, put_flash(socket, :error, "You must be logged in to favorite sounds")}
user ->
handle_favorite_toggle(socket, user, sound_name)
end
end
@impl true
def handle_event("previous_week", _, socket) do
{start_date, _} = socket.assigns.selected_week
new_week = get_week_range(Date.add(start_date, -7))
{:noreply,
socket
|> assign(:selected_week, new_week)
|> assign_stats()}
end
@impl true
def handle_event("next_week", _, socket) do
{start_date, _} = socket.assigns.selected_week
new_week = get_week_range(Date.add(start_date, 7))
case Date.compare(elem(new_week, 1), elem(socket.assigns.current_week, 1)) do
:gt -> {:noreply, socket}
_ -> {:noreply, socket |> assign(:selected_week, new_week) |> assign_stats()}
end
end
@impl true
def handle_event("select_week", %{"week" => week_value}, socket) do
current_week = socket.assigns.current_week
case parse_week_input(week_value) do
{:ok, new_week} ->
if Date.compare(elem(new_week, 1), elem(current_week, 1)) == :gt do
{:noreply, socket}
else
{:noreply,
socket
|> assign(:selected_week, new_week)
|> assign_stats()}
end
:error ->
{:noreply, socket}
end
end
defp favorite?(favorites, sound_name, sound_ids_by_filename) do
case Map.get(sound_ids_by_filename, sound_name) do
nil -> false
sound_id -> Enum.member?(favorites, sound_id)
end
end
defp get_user_avatar(username, presences, avatars_by_username) do
presences
|> Enum.find_value(fn {_id, presence} ->
meta = List.first(presence.metas)
if get_in(meta, [:user, :username]) == username, do: get_in(meta, [:user, :avatar])
end) || Map.get(avatars_by_username, username)
end
end
================================================
FILE: lib/soundboard_web/live/support/flash_helpers.ex
================================================
defmodule SoundboardWeb.Live.Support.FlashHelpers do
@moduledoc false
import Phoenix.LiveView, only: [put_flash: 3]
def flash_sound_played(socket, %{filename: filename, played_by: username}) do
socket
|> put_flash(:info, "#{username} played #{filename}")
|> clear_flash_after_timeout()
end
def clear_flash_after_timeout(socket) do
Process.send_after(self(), :clear_flash, 3000)
socket
end
end
================================================
FILE: lib/soundboard_web/live/support/live_tags.ex
================================================
defmodule SoundboardWeb.Live.Support.LiveTags do
@moduledoc """
LiveView-facing tag queries and mutations for the soundboard.
"""
alias Soundboard.{PubSubTopics, Sounds.Tags}
def add_tag(tag_name, current_tags, apply_tag_fun) when is_function(apply_tag_fun, 2) do
with {:ok} <- validate_tag_name(tag_name),
{:ok, tag} <- Tags.find_or_create(tag_name),
{:ok} <- validate_unique_tag(tag, current_tags) do
apply_tag_fun.(tag, current_tags)
end
end
def search(query), do: Tags.search(query)
def all_tags(sounds), do: Tags.all_for_sounds(sounds)
def count_sounds_with_tag(sounds, tag), do: Tags.count_sounds_with_tag(sounds, tag)
def tag_selected?(tag, selected_tags), do: Tags.tag_selected?(tag, selected_tags)
def update_sound_tags(sound, tags), do: Tags.update_sound_tags(sound, tags)
def find_or_create_tag(name), do: Tags.find_or_create(name)
def list_tags_for_sound(filename), do: Tags.list_for_sound(filename)
def broadcast_update do
PubSubTopics.broadcast_files_updated()
end
defp validate_tag_name(tag_name) do
if String.trim(to_string(tag_name)) == "" do
{:error, "Tag name cannot be empty"}
else
{:ok}
end
end
defp validate_unique_tag(tag, current_tags) do
if Enum.any?(current_tags, &(&1.id == tag.id)) do
{:error, "Tag already exists"}
else
{:ok}
end
end
end
================================================
FILE: lib/soundboard_web/live/support/presence_live.ex
================================================
defmodule SoundboardWeb.Live.Support.PresenceLive do
defmacro __using__(_opts) do
quote do
alias SoundboardWeb.{Presence, PresenceHandler}
require Logger
@presence_topic "soundboard:presence"
def mount_presence(socket, session) do
if connected?(socket) do
user = get_user_from_session(session)
Phoenix.PubSub.subscribe(Soundboard.PubSub, @presence_topic)
Process.put(:connected_pid, self())
Process.put(:socket_id, socket.id)
Process.put(:current_user, user)
if user do
{:ok, _} =
Presence.track(self(), @presence_topic, socket.id, %{
user: %{
username: user.username,
avatar: user.avatar
},
online_at: System.system_time(:second)
})
end
end
socket
|> assign(:presences, Presence.list(@presence_topic))
|> assign(:presence_count, map_size(Presence.list(@presence_topic)))
end
defp get_user_from_session(%{"user_id" => user_id}),
do: Soundboard.Repo.get(Soundboard.Accounts.User, user_id)
defp get_user_from_session(_), do: nil
@impl true
def handle_info({:presence_update, presences}, socket) do
{:noreply, assign(socket, :presences, presences)}
end
@impl true
def handle_info({:presence_diff, diff}, socket) do
{:noreply,
assign(socket,
presence_count:
PresenceHandler.handle_presence_diff(
diff,
socket.assigns.presence_count
)
)}
end
@impl true
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do
presences = Presence.list(@presence_topic)
{:noreply,
socket
|> assign(:presences, presences)
|> assign(
:presence_count,
PresenceHandler.handle_presence_diff(diff, socket.assigns.presence_count)
)}
end
end
end
end
================================================
FILE: lib/soundboard_web/live/support/sound_playback.ex
================================================
defmodule SoundboardWeb.Live.Support.SoundPlayback do
@moduledoc false
import Phoenix.LiveView, only: [put_flash: 3]
alias Soundboard.Accounts.User
def play(socket, sound_name) do
case socket.assigns[:current_user] do
%User{} = user ->
Soundboard.AudioPlayer.play_sound(sound_name, user)
{:noreply, socket}
_ ->
{:noreply, put_flash(socket, :error, "You must be logged in to play sounds")}
end
end
def current_username(socket) do
case socket.assigns[:current_user] do
%User{username: username} -> {:ok, username}
_ -> :error
end
end
end
================================================
FILE: lib/soundboard_web/live/support/tag_form.ex
================================================
defmodule SoundboardWeb.Live.Support.TagForm do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [put_flash: 3]
alias SoundboardWeb.Live.Support.LiveTags
@type config :: %{required(:input_key) => atom(), required(:suggestions_key) => atom()}
def handle_key(socket, key, value, current_tags, apply_tag_fun, config)
when is_function(apply_tag_fun, 3) and is_map(config) do
if key == "Enter" and value != "" do
select_tag(socket, value, current_tags, apply_tag_fun, config)
else
update_input(socket, value, config)
end
end
def select_tag(socket, tag_name, current_tags, apply_tag_fun, config)
when is_function(apply_tag_fun, 3) and is_map(config) do
tag_name
|> LiveTags.add_tag(current_tags, fn tag, tags -> apply_tag_fun.(socket, tag, tags) end)
|> handle_result(socket, config)
end
def update_input(socket, value, %{input_key: input_key, suggestions_key: suggestions_key}) do
suggestions = LiveTags.search(value)
{:noreply,
socket
|> assign(input_key, value)
|> assign(suggestions_key, suggestions)}
end
defp handle_result({:ok, updated_socket}, _socket, config) do
{:noreply, reset(updated_socket, config)}
end
defp handle_result({:error, message}, socket, config) do
{:noreply,
socket
|> reset(config)
|> put_flash(:error, message)}
end
defp reset(socket, %{input_key: input_key, suggestions_key: suggestions_key}) do
socket
|> assign(input_key, "")
|> assign(suggestions_key, [])
end
end
================================================
FILE: lib/soundboard_web/plugs/api_auth.ex
================================================
defmodule SoundboardWeb.Plugs.APIAuth do
@moduledoc """
API authentication plug.
"""
import Plug.Conn
alias Soundboard.Accounts.ApiTokens
def init(opts), do: opts
def call(conn, _opts) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] ->
authenticate_with_token(conn, token)
_ ->
unauthorized(conn)
end
end
defp authenticate_with_token(conn, token) do
case verify_db_token(token) do
{:ok, user, api_token} ->
conn
|> assign(:current_user, user)
|> assign(:api_token, api_token)
{:error, :invalid} ->
unauthorized(conn)
{:error, :token_update_failed} ->
internal_error(conn)
end
end
defp unauthorized(conn, message \\ "Invalid API token") do
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.json(%{error: message})
|> halt()
end
defp internal_error(conn) do
conn
|> put_status(:internal_server_error)
|> Phoenix.Controller.json(%{error: "API token verification failed"})
|> halt()
end
defp verify_db_token(token), do: ApiTokens.verify_token(token)
end
================================================
FILE: lib/soundboard_web/plugs/basic_auth.ex
================================================
defmodule SoundboardWeb.Plugs.BasicAuth do
@moduledoc """
Basic authentication plug.
When both `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables
are set to non-blank values, every browser request must supply matching Basic
credentials. When either variable is missing or blank, basic auth is disabled
and all requests pass through.
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
username = credential("BASIC_AUTH_USERNAME")
password = credential("BASIC_AUTH_PASSWORD")
case {username, password} do
{nil, nil} ->
conn
{username, password} when is_binary(username) and is_binary(password) ->
authenticate(conn, username, password)
_ ->
Logger.warning("Basic auth is partially configured; failing closed")
unauthorized(conn)
end
end
defp credential(key) do
case System.get_env(key) do
nil ->
nil
value when is_binary(value) ->
if String.trim(value) == "" do
nil
else
value
end
end
end
defp authenticate(conn, username, password) do
with ["Basic " <> auth] <- get_req_header(conn, "authorization"),
{:ok, decoded} <- Base.decode64(auth),
{provided_username, provided_password} <- split_credentials(decoded),
true <- provided_username == username and provided_password == password do
conn
else
_ -> unauthorized(conn)
end
end
defp split_credentials(decoded) do
case String.split(decoded, ":", parts: 2) do
[username, password] -> {username, password}
_ -> :error
end
end
defp unauthorized(conn) do
conn
|> put_resp_header("www-authenticate", ~s(Basic realm="Soundboard"))
|> put_resp_content_type("text/plain")
|> send_resp(401, "Unauthorized")
|> halt()
end
end
================================================
FILE: lib/soundboard_web/plugs/role_check.ex
================================================
defmodule SoundboardWeb.Plugs.RoleCheck do
@moduledoc false
require Logger
import Plug.Conn
import Phoenix.Controller
alias Soundboard.Discord.RoleChecker
def init(opts), do: opts
def call(conn, _opts) do
cond do
is_nil(conn.assigns[:current_user]) -> conn
not RoleChecker.feature_enabled?() -> conn
true -> check_role(conn)
end
end
defp check_role(conn) do
roles_verified_at = get_session(conn, :roles_verified_at)
recheck_interval = Application.get_env(:soundboard, :role_recheck_interval_seconds, 900)
if fresh?(roles_verified_at, recheck_interval) do
conn
else
discord_id = conn.assigns.current_user.discord_id
if RoleChecker.authorized?(discord_id) do
put_session(conn, :roles_verified_at, System.system_time(:second))
else
Logger.warning("Role check failed for Discord user #{discord_id}, clearing session")
conn
|> clear_session()
|> put_flash(:error, "Error signing in")
|> redirect(to: "/")
|> halt()
end
end
end
defp fresh?(nil, _interval), do: false
defp fresh?(verified_at, interval) when is_integer(verified_at) do
System.system_time(:second) - verified_at < interval
end
end
================================================
FILE: lib/soundboard_web/presence.ex
================================================
defmodule SoundboardWeb.Presence do
@moduledoc """
The Presence module.
"""
use Phoenix.Presence,
otp_app: :soundboard,
pubsub_server: Soundboard.PubSub
end
================================================
FILE: lib/soundboard_web/presence_handler.ex
================================================
defmodule SoundboardWeb.PresenceHandler do
@moduledoc """
Handles presence tracking for the Soundboard app.
"""
use GenServer
import Phoenix.LiveView, only: [connected?: 1]
alias SoundboardWeb.Presence
@presence_topic "soundboard:presence"
@colors [
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
"bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200",
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200",
"bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
"bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
"bg-slate-100 text-slate-800 dark:bg-slate-900 dark:text-slate-200",
"bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200",
"bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200",
"bg-stone-100 text-stone-800 dark:bg-stone-900 dark:text-stone-200",
"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200"
]
@colors_key :user_colors
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
:persistent_term.put(@colors_key, %{})
{:ok, %{}}
end
def track_presence(socket, user) do
if connected?(socket) do
username = if user, do: user.username, else: "Anonymous #{socket.id |> String.slice(0..5)}"
color = get_random_unique_color(username)
Presence.track(self(), @presence_topic, socket.id, %{
online_at: System.system_time(:second),
user: %{
username: username,
avatar: if(user, do: user.avatar, else: nil),
color: color
}
})
end
end
@spec get_user_color(String.t()) :: String.t()
def get_user_color(username) do
colors = :persistent_term.get(@colors_key, %{})
Map.get(colors, username) || get_random_unique_color(username)
end
defp get_random_unique_color(username) do
colors = :persistent_term.get(@colors_key, %{})
used_colors = Map.values(colors)
available_colors = Enum.reject(@colors, &(&1 in used_colors))
color =
if Enum.empty?(available_colors) do
# If all colors are used, pick a random one
Enum.random(@colors)
else
Enum.random(available_colors)
end
:persistent_term.put(@colors_key, Map.put(colors, username, color))
color
end
def get_presence_count do
@presence_topic
|> Presence.list()
|> count_active_presences()
end
def handle_presence_diff(%{joins: joins, leaves: leaves}, current_count) do
now = System.system_time(:second)
active_joins = count_active_presences(joins, now)
active_leaves = count_active_presences(leaves, now)
max(current_count + (active_joins - active_leaves), 0)
end
defp count_active_presences(presences) do
now = System.system_time(:second)
count_active_presences(presences, now)
end
defp count_active_presences(presences, now) do
Enum.count(presences, fn {_id, presence} ->
metas = presence.metas || []
Enum.any?(metas, fn %{online_at: online_at} ->
now - online_at < 60
end)
end)
end
end
================================================
FILE: lib/soundboard_web/router.ex
================================================
defmodule SoundboardWeb.Router do
use SoundboardWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {SoundboardWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :require_browser_basic_auth do
plug SoundboardWeb.Plugs.BasicAuth
end
pipeline :require_role_check do
plug SoundboardWeb.Plugs.RoleCheck
end
pipeline :auth do
plug :fetch_session
plug :fetch_current_user
end
pipeline :auth_browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_session_opts
end
pipeline :api do
plug :accepts, ["json"]
plug SoundboardWeb.Plugs.APIAuth
end
# Discord OAuth routes - must come before protected routes
scope "/auth", SoundboardWeb do
pipe_through [:browser]
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :logout
end
# Protected routes
scope "/", SoundboardWeb do
pipe_through [
:browser,
:auth,
:ensure_authenticated_user,
:require_role_check,
:require_browser_basic_auth
]
live "/", SoundboardLive
live "/stats", StatsLive
live "/favorites", FavoritesLive
live "/settings", SettingsLive
end
scope "/uploads" do
pipe_through [
:browser,
:auth,
:ensure_authenticated_user,
:require_role_check,
:require_browser_basic_auth
]
get "/*path", SoundboardWeb.UploadController, :show
end
if Mix.env() == :test do
scope "/debug", SoundboardWeb do
pipe_through [:browser]
get "/session", AuthController, :debug_session
end
end
# Add this new scope for API routes before your other scopes
scope "/api", SoundboardWeb.API do
pipe_through :api
get "/sounds", SoundController, :index
post "/sounds", SoundController, :create
post "/sounds/:id/play", SoundController, :play
post "/sounds/stop", SoundController, :stop
end
def fetch_current_user(conn, _) do
user_id = get_session(conn, :user_id)
if user_id do
case Soundboard.Accounts.get_user(user_id) do
nil ->
conn
|> clear_session()
|> assign(:current_user, nil)
user ->
assign(conn, :current_user, user)
end
else
assign(conn, :current_user, nil)
end
end
def ensure_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_session(:return_to, conn.request_path)
|> redirect(to: "/auth/discord")
|> halt()
end
end
defp put_session_opts(conn, _opts) do
conn
|> put_resp_cookie("_soundboard_key", "",
max_age: 86_400 * 30,
same_site: "Lax",
secure: Application.get_env(:soundboard, :env) == :prod,
http_only: true,
path: "/"
)
end
end
================================================
FILE: lib/soundboard_web/sound_helpers.ex
================================================
defmodule SoundboardWeb.SoundHelpers do
@moduledoc """
Shared helpers for formatting sound metadata for UI rendering.
"""
def display_name(nil), do: ""
def display_name(filename) when is_binary(filename) do
filename
|> Path.basename()
|> Path.rootname()
end
def display_name(other), do: to_string(other)
def slugify(name) do
name
|> display_name()
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "-", global: true)
|> String.trim("-")
|> ensure_slug()
end
defp ensure_slug(""), do: "sound"
defp ensure_slug(slug), do: slug
end
================================================
FILE: lib/soundboard_web/soundboard/sound_filter.ex
================================================
defmodule SoundboardWeb.Soundboard.SoundFilter do
@moduledoc """
Filters sounds based on the selected tags and search query.
"""
def filter_sounds(sounds, query, selected_tags) do
sounds
|> filter_by_tags(selected_tags)
|> filter_by_search(query)
end
defp filter_by_tags(sounds, []), do: sounds
defp filter_by_tags(sounds, selected_tags) do
selected_tag_ids = MapSet.new(selected_tags, & &1.id)
Enum.filter(sounds, fn sound ->
sound_tag_ids = MapSet.new(sound.tags, & &1.id)
MapSet.subset?(selected_tag_ids, sound_tag_ids)
end)
end
defp filter_by_search(sounds, ""), do: sounds
defp filter_by_search(sounds, query) do
query = String.downcase(query)
Enum.filter(sounds, fn sound ->
filename_matches = String.downcase(sound.filename) =~ query
tag_matches =
Enum.any?(sound.tags, fn tag ->
String.downcase(tag.name) =~ query
end)
filename_matches || tag_matches
end)
end
end
================================================
FILE: lib/soundboard_web/telemetry.ex
================================================
defmodule SoundboardWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("soundboard.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("soundboard.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("soundboard.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("soundboard.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("soundboard.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {SoundboardWeb, :count_users, []}
]
end
end
================================================
FILE: lib/soundboard_web.ex
================================================
defmodule SoundboardWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use SoundboardWeb, :controller
use SoundboardWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: SoundboardWeb.Layouts]
use Gettext, backend: SoundboardWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {SoundboardWeb.Layouts, :app}
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: SoundboardWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import SoundboardWeb.CoreComponents
import SoundboardWeb.SoundHelpers
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: SoundboardWeb.Endpoint,
router: SoundboardWeb.Router,
statics: SoundboardWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
================================================
FILE: mix.exs
================================================
defmodule Soundboard.MixProject do
use Mix.Project
@moduledoc """
Mix project configuration for Soundbored, the self-hosted Discord soundboard
that powers audio playback, live dashboards, and API integrations described in
the repository README.
"""
def project do
[
app: :soundboard,
version: "1.7.0",
elixir: "~> 1.19",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
listeners: [Phoenix.CodeReloader],
aliases: aliases(),
deps: deps(),
test_coverage: [
tool: ExCoveralls,
ignore_modules: [
SoundboardWeb.CoreComponents,
SoundboardWeb.Components.FlashComponent,
SoundboardWeb.Components.Layouts,
SoundboardWeb.Router,
SoundboardWeb.Telemetry,
SoundboardWeb.Endpoint,
SoundboardWeb.Gettext,
# Controllers and views with no meaningful coverage needs
SoundboardWeb.ErrorHTML,
SoundboardWeb.ErrorJSON,
SoundboardWeb.PageController,
SoundboardWeb.PageHTML,
SoundboardWeb.UploadController,
# Live views that might need separate testing strategy
SoundboardWeb.PresenceLive,
SoundboardWeb.Presence,
# Repo and application modules
Soundboard.Repo,
# Test support files
SoundboardWeb.ConnCase,
Soundboard.DataCase,
Soundboard.TestHelpers
]
]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
apps = [:logger, :runtime_tools]
[
mod: {Soundboard.Application, []},
extra_applications: apps,
included_applications: []
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.0"},
{:phoenix_ecto, "~> 4.6.5"},
{:ecto_sql, "~> 3.10"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.6", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:floki, ">= 0.30.0", only: :test},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.7"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.1", app: false, compile: false},
{:telemetry_metrics, "~> 1.1"},
{:telemetry_poller, "~> 1.3"},
{:gettext, "~> 1.0"},
{:jason, "~> 1.2"},
{:bandit, "~> 1.8"},
{:eda, "~> 0.1.3"},
{:rustler, "~> 0.35", runtime: false},
{:ecto_sqlite3, "~> 0.22"},
{:ueberauth, "~> 0.10.5"},
{:ueberauth_discord, "~> 0.6"},
{:mock, "~> 0.3.9", only: :test},
{:dotenvy, "~> 1.0.0", runtime: false},
{:excoveralls, "~> 0.18.5", only: :test},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:ex_dna, "~> 1.1", only: [:dev, :test], runtime: false},
{:ex_slop, "~> 0.2", only: [:dev, :test], runtime: false}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
precommit: [
"compile --warnings-as-errors",
"deps.unlock --unused",
"format",
"credo --strict",
"cmd env MIX_ENV=test mix test",
"ex_dna"
],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["tailwind soundboard", "esbuild soundboard"],
"assets.deploy": [
"tailwind soundboard --minify",
"esbuild soundboard --minify",
"phx.digest"
]
]
end
def cli do
[
preferred_envs: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test,
"coveralls.json": :test,
"coveralls.github": :test
]
]
end
end
================================================
FILE: priv/gettext/en/LC_MESSAGES/errors.po
================================================
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""
================================================
FILE: priv/gettext/errors.pot
================================================
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""
================================================
FILE: priv/repo/migrations/.formatter.exs
================================================
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]
================================================
FILE: priv/repo/migrations/20250101213201_create_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateSounds do
use Ecto.Migration
def change do
create table(:sounds) do
add :filename, :string, null: false
add :tags, {:array, :string}, default: []
add :description, :text
timestamps()
end
create unique_index(:sounds, [:filename])
end
end
================================================
FILE: priv/repo/migrations/20250101213717_create_tags.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateTags do
use Ecto.Migration
def change do
create table(:tags) do
add :name, :string, null: false
timestamps()
end
create unique_index(:tags, [:name])
create table(:sound_tags) do
add :sound_id, references(:sounds, on_delete: :delete_all), null: false
add :tag_id, references(:tags, on_delete: :delete_all), null: false
timestamps()
end
create unique_index(:sound_tags, [:sound_id, :tag_id])
end
end
================================================
FILE: priv/repo/migrations/20250101231744_create_users.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :discord_id, :string, null: false
add :username, :string, null: false
add :avatar, :string
timestamps()
end
create unique_index(:users, [:discord_id])
end
end
================================================
FILE: priv/repo/migrations/20250102212120_create_plays.exs
================================================
defmodule Soundboard.Repo.Migrations.CreatePlays do
use Ecto.Migration
def change do
create table(:plays) do
add :sound_name, :string, null: false
add :user_id, references(:users, on_delete: :delete_all), null: false
timestamps()
end
create index(:plays, [:user_id])
create index(:plays, [:sound_name])
end
end
================================================
FILE: priv/repo/migrations/20250102212121_create_favorites.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateFavorites do
use Ecto.Migration
def change do
create table(:favorites) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :filename, :string, null: false
timestamps()
end
create unique_index(:favorites, [:user_id, :filename])
end
end
================================================
FILE: priv/repo/migrations/20250102212122_add_user_id_to_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.AddUserIdToSounds do
use Ecto.Migration
def change do
alter table(:sounds) do
add :user_id, references(:users, on_delete: :nilify_all)
end
end
end
================================================
FILE: priv/repo/migrations/20250102212123_change_favorites_filename_to_sound_id.exs
================================================
defmodule Soundboard.Repo.Migrations.ChangeFavoritesFilenameToSoundId do
use Ecto.Migration
def up do
# First add the new column while keeping the old one
alter table(:favorites) do
add :sound_id, :integer
end
# Copy data from filename to sound_id by joining with sounds table
execute """
UPDATE favorites SET sound_id = (
SELECT id FROM sounds WHERE sounds.filename = favorites.filename
)
"""
# Create a new table with the desired schema
create table(:favorites_new) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :sound_id, :integer, null: false
timestamps()
end
# Copy data to the new table
execute """
INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)
SELECT user_id, sound_id, inserted_at, updated_at FROM favorites
WHERE sound_id IS NOT NULL
"""
# Drop the old table and rename the new one
drop table(:favorites)
execute "ALTER TABLE favorites_new RENAME TO favorites"
# Create the new index
create unique_index(:favorites, [:user_id, :sound_id])
end
def down do
# Create a new table with the old schema
create table(:favorites_new) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :filename, :string, null: false
timestamps()
end
# Copy data back by joining with sounds table
execute """
INSERT INTO favorites_new (user_id, filename, inserted_at, updated_at)
SELECT f.user_id, s.filename, f.inserted_at, f.updated_at
FROM favorites f
JOIN sounds s ON s.id = f.sound_id
"""
# Drop the old table and rename the new one
drop table(:favorites)
execute "ALTER TABLE favorites_new RENAME TO favorites"
# Recreate the old index
create unique_index(:favorites, [:user_id, :filename],
name: :favorites_user_id_filename_index
)
end
end
================================================
FILE: priv/repo/migrations/20250102212124_add_index_to_plays.exs
================================================
defmodule Soundboard.Repo.Migrations.AddIndexToPlays do
use Ecto.Migration
def change do
create index(:plays, [:inserted_at])
end
end
================================================
FILE: priv/repo/migrations/20250102212125_add_join_leave_flags_to_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.AddJoinLeaveFlagsToSounds do
use Ecto.Migration
def change do
alter table(:sounds) do
add :is_join_sound, :boolean, default: false
add :is_leave_sound, :boolean, default: false
end
# Ensure only one join and one leave sound per user
create unique_index(:sounds, [:user_id, :is_join_sound],
name: :user_join_sound_index,
# Use proper SQL boolean
where: "is_join_sound = TRUE"
)
create unique_index(:sounds, [:user_id, :is_leave_sound],
name: :user_leave_sound_index,
# Use proper SQL boolean
where: "is_leave_sound = TRUE"
)
end
end
================================================
FILE: priv/repo/migrations/20250102212126_add_url_to_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.AddUrlToSounds do
use Ecto.Migration
def change do
alter table(:sounds) do
add :url, :string
add :source_type, :string, default: "local", null: false
end
end
end
================================================
FILE: priv/repo/migrations/20250218214831_create_user_sound_settings.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateUserSoundSettings do
use Ecto.Migration
def change do
create table(:user_sound_settings) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :sound_id, references(:sounds, on_delete: :delete_all), null: false
add :is_join_sound, :boolean, default: false
add :is_leave_sound, :boolean, default: false
timestamps()
end
create index(:user_sound_settings, [:user_id])
create index(:user_sound_settings, [:sound_id])
create unique_index(:user_sound_settings, [:user_id, :is_join_sound],
where: "is_join_sound = 1",
name: :user_sound_settings_join_sound_index
)
create unique_index(:user_sound_settings, [:user_id, :is_leave_sound],
where: "is_leave_sound = 1",
name: :user_sound_settings_leave_sound_index
)
execute """
INSERT INTO user_sound_settings (user_id, sound_id, is_join_sound, is_leave_sound, inserted_at, updated_at)
SELECT user_id, id, COALESCE(is_join_sound, 0), COALESCE(is_leave_sound, 0), datetime('now'), datetime('now')
FROM sounds
WHERE is_join_sound = 1 OR is_leave_sound = 1;
""",
"""
DELETE FROM user_sound_settings;
"""
end
end
================================================
FILE: priv/repo/migrations/20250218214832_remove_join_leave_flags_from_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.RemoveJoinLeaveFlagsFromSounds do
use Ecto.Migration
def change do
drop_if_exists index(:sounds, [:is_join_sound], name: :user_join_sound_index)
drop_if_exists index(:sounds, [:is_leave_sound], name: :user_leave_sound_index)
alter table(:sounds) do
remove :is_join_sound
remove :is_leave_sound
end
end
end
================================================
FILE: priv/repo/migrations/20250218220000_create_api_tokens.exs
================================================
defmodule Soundboard.Repo.Migrations.CreateApiTokens do
use Ecto.Migration
def change do
create table(:api_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token_hash, :string, null: false
add :label, :string
add :revoked_at, :naive_datetime
add :last_used_at, :naive_datetime
timestamps()
end
create unique_index(:api_tokens, [:token_hash])
create index(:api_tokens, [:user_id])
end
end
================================================
FILE: priv/repo/migrations/20250218223000_add_token_plain_to_api_tokens.exs
================================================
defmodule Soundboard.Repo.Migrations.AddTokenPlainToApiTokens do
use Ecto.Migration
def change do
alter table(:api_tokens) do
add :token, :string, null: false, default: ""
end
end
end
================================================
FILE: priv/repo/migrations/20250310120000_add_volume_to_sounds.exs
================================================
defmodule Soundboard.Repo.Migrations.AddVolumeToSounds do
use Ecto.Migration
def change do
alter table(:sounds) do
add :volume, :float, default: 1.0, null: false
end
end
end
================================================
FILE: priv/repo/migrations/20260306150000_add_sound_id_to_plays.exs
================================================
defmodule Soundboard.Repo.Migrations.AddSoundIdToPlays do
use Ecto.Migration
def up do
alter table(:plays) do
add :sound_id, references(:sounds, on_delete: :nilify_all)
end
execute("""
UPDATE plays
SET sound_id = (
SELECT sounds.id
FROM sounds
WHERE sounds.filename = plays.sound_name
)
WHERE sound_id IS NULL
""")
create index(:plays, [:sound_id])
end
def down do
drop index(:plays, [:sound_id])
alter table(:plays) do
remove :sound_id
end
end
end
================================================
FILE: priv/repo/migrations/20260306151000_finalize_favorites_and_sound_tags_migrations.exs
================================================
defmodule Soundboard.Repo.Migrations.FinalizeFavoritesAndSoundTagsMigrations do
use Ecto.Migration
def up do
create table(:favorites_new) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :sound_id, references(:sounds, on_delete: :delete_all), null: false
timestamps()
end
execute("""
INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)
SELECT f.user_id, f.sound_id, f.inserted_at, f.updated_at
FROM favorites f
JOIN sounds s ON s.id = f.sound_id
""")
drop table(:favorites)
execute("ALTER TABLE favorites_new RENAME TO favorites")
create unique_index(:favorites, [:user_id, :sound_id])
create index(:favorites, [:sound_id])
execute("""
INSERT INTO tags (name, inserted_at, updated_at)
SELECT DISTINCT lower(trim(j.value)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM sounds s
JOIN json_each(s.tags) AS j
WHERE json_valid(s.tags)
AND trim(j.value) != ''
AND NOT EXISTS (
SELECT 1 FROM tags existing WHERE existing.name = lower(trim(j.value))
)
""")
execute("""
INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at)
SELECT s.id, t.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM sounds s
JOIN json_each(s.tags) AS j
JOIN tags t ON t.name = lower(trim(j.value))
WHERE json_valid(s.tags)
AND trim(j.value) != ''
AND NOT EXISTS (
SELECT 1
FROM sound_tags st
WHERE st.sound_id = s.id AND st.tag_id = t.id
)
""")
alter table(:sounds) do
remove :tags
end
end
def down do
alter table(:sounds) do
add :tags, {:array, :string}, default: []
end
execute("""
UPDATE sounds
SET tags = COALESCE((
SELECT json_group_array(name)
FROM (
SELECT t.name AS name
FROM sound_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.sound_id = sounds.id
ORDER BY t.name
)
), '[]')
""")
create table(:favorites_new) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :sound_id, :integer, null: false
timestamps()
end
execute("""
INSERT INTO favorites_new (user_id, sound_id, inserted_at, updated_at)
SELECT user_id, sound_id, inserted_at, updated_at
FROM favorites
""")
drop table(:favorites)
execute("ALTER TABLE favorites_new RENAME TO favorites")
create unique_index(:favorites, [:user_id, :sound_id])
end
end
================================================
FILE: priv/repo/migrations/20260307211000_rename_sound_name_to_played_filename_in_plays.exs
================================================
defmodule Soundboard.Repo.Migrations.RenameSoundNameToPlayedFilenameInPlays do
use Ecto.Migration
def up do
drop_if_exists index(:plays, [:sound_name])
rename table(:plays), :sound_name, to: :played_filename
execute("""
UPDATE plays
SET sound_id = (
SELECT sounds.id
FROM sounds
WHERE sounds.filename = plays.played_filename
)
WHERE sound_id IS NULL
""")
create index(:plays, [:played_filename])
end
def down do
drop_if_exists index(:plays, [:played_filename])
rename table(:plays), :played_filename, to: :sound_name
create index(:plays, [:sound_name])
end
end
================================================
FILE: priv/repo/seeds.exs
================================================
# Soundbored has no default seed data. Add only idempotent local bootstrap data here when needed.
================================================
FILE: priv/static/manifest.json
================================================
{
"name": "SoundBored",
"short_name": "SoundBored",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1f2937",
"theme_color": "#1f2937",
"scope": "/",
"icons": [
{
"src": "/images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
================================================
FILE: priv/static/robots.txt
================================================
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /
================================================
FILE: test/soundboard/accounts/api_tokens_test.exs
================================================
defmodule Soundboard.Accounts.ApiTokensTest do
use Soundboard.DataCase
import Mock
alias Soundboard.Accounts.{ApiToken, ApiTokens, User}
alias Soundboard.Repo
setup do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "apitok_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "test.jpg"
})
|> Repo.insert()
%{user: user}
end
test "generate, verify, revoke token lifecycle", %{user: user} do
{:ok, raw, token_rec} = ApiTokens.generate_token(user, %{label: "CI"})
assert is_binary(raw) and String.starts_with?(raw, "sb_")
assert token_rec.user_id == user.id
assert token_rec.token == raw
assert token_rec.token_hash != nil
# verify returns user and updates last_used_at
assert {:ok, ^user, verified_token} = ApiTokens.verify_token(raw)
# Reload to ensure last_used_at persisted
reloaded = Repo.get(Soundboard.Accounts.ApiToken, verified_token.id)
assert reloaded.last_used_at != nil
# list_tokens includes it while active
assert [listed] = ApiTokens.list_tokens(user)
assert listed.id == token_rec.id
# revoke and ensure it's hidden and cannot verify
assert {:ok, _} = ApiTokens.revoke_token(user, token_rec.id)
assert [] == ApiTokens.list_tokens(user)
assert {:error, :invalid} == ApiTokens.verify_token(raw)
end
test "verify_token returns error for invalid token", %{user: _user} do
# ensure user created to avoid false positives
assert {:error, :invalid} == ApiTokens.verify_token("sb_invalid_token")
end
test "verify_token returns error when last_used_at update fails", %{user: user} do
{:ok, raw, token} = ApiTokens.generate_token(user, %{label: "failing-update"})
stored_token = Repo.get!(ApiToken, token.id)
preloaded_token = Repo.preload(stored_token, :user)
failed_changeset = Ecto.Changeset.change(stored_token)
with_mock Soundboard.Repo,
one: fn _query -> stored_token end,
preload: fn ^stored_token, :user -> preloaded_token end,
update: fn _changeset -> {:error, failed_changeset} end do
assert {:error, :token_update_failed} == ApiTokens.verify_token(raw)
end
end
test "revoke_token forbids other users", %{user: user} do
{:ok, _raw, token} = ApiTokens.generate_token(user, %{label: "owner"})
{:ok, other} =
%User{}
|> User.changeset(%{
username: "apitok_other_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive]) + 1),
avatar: "a.jpg"
})
|> Repo.insert()
assert {:error, :forbidden} == ApiTokens.revoke_token(other, token.id)
end
test "list_tokens empty for new user and revoke not_found on unknown id" do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "apitok_empty_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive]) + 2),
avatar: "b.jpg"
})
|> Repo.insert()
assert [] == ApiTokens.list_tokens(user)
# Passing string id should be normalized but not found
assert {:error, :not_found} == ApiTokens.revoke_token(user, "999999")
# Passing invalid string normalizes to -1 and should still be not_found
assert {:error, :not_found} == ApiTokens.revoke_token(user, "not_an_int")
end
test "revoke_token rejects partially parsed string ids", %{user: user} do
{:ok, _raw, token} = ApiTokens.generate_token(user, %{label: "strict-id"})
assert {:error, :not_found} == ApiTokens.revoke_token(user, "#{token.id}garbage")
assert [listed] = ApiTokens.list_tokens(user)
assert listed.id == token.id
end
end
================================================
FILE: test/soundboard/accounts_test.exs
================================================
defmodule Soundboard.AccountsTest do
use Soundboard.DataCase
alias Soundboard.Accounts
alias Soundboard.Accounts.User
alias Soundboard.Repo
test "get_user/1 returns the persisted user" do
user =
%User{}
|> User.changeset(%{
username: "accounts_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert!()
user_id = user.id
assert %User{id: ^user_id} = Accounts.get_user(user.id)
end
test "get_user/1 returns nil for missing users" do
assert Accounts.get_user(-1) == nil
end
test "avatars_by_usernames/1 returns avatars keyed by username" do
user =
%User{}
|> User.changeset(%{
username: "avatars_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar-keyed.png"
})
|> Repo.insert!()
assert Accounts.avatars_by_usernames([]) == %{}
assert Accounts.avatars_by_usernames([user.username, "missing"]) == %{
user.username => "avatar-keyed.png"
}
end
end
================================================
FILE: test/soundboard/audio_player/playback_engine_test.exs
================================================
defmodule Soundboard.AudioPlayer.PlaybackEngineTest do
use ExUnit.Case, async: false
import ExUnit.CaptureLog
import Mock
alias Soundboard.AudioPlayer.PlaybackEngine
setup do
previous_probe = Application.get_env(:soundboard, :voice_rtp_probe)
previous_ffmpeg = Application.get_env(:soundboard, :ffmpeg_executable, :system)
Application.put_env(:soundboard, :voice_rtp_probe, false)
Application.put_env(:soundboard, :ffmpeg_executable, "/usr/bin/ffmpeg")
on_exit(fn ->
if is_nil(previous_probe) do
Application.delete_env(:soundboard, :voice_rtp_probe)
else
Application.put_env(:soundboard, :voice_rtp_probe, previous_probe)
end
case previous_ffmpeg do
:system -> Application.delete_env(:soundboard, :ffmpeg_executable)
value -> Application.put_env(:soundboard, :ffmpeg_executable, value)
end
end)
:ok
end
test "joins the requested channel before playing" do
test_pid = self()
with_mocks([
{Soundboard.AudioPlayer.SoundLibrary, [],
[prepare_play_input: fn "intro.mp3", "/tmp/intro.mp3" -> {"/tmp/intro.mp3", :url} end]},
{Soundboard.Discord.Voice, [],
[
channel_id: fn "guild-1" -> nil end,
join_channel: fn "guild-1", "channel-9" -> send(test_pid, :joined_channel) end,
ready?: fn "guild-1" -> true end,
play: fn "guild-1", "/tmp/intro.mp3", :url, [volume: 0.8] ->
send(test_pid, :played_sound)
:ok
end
]},
{Soundboard.PubSubTopics, [],
[broadcast_sound_played: fn "intro.mp3", "System" -> send(test_pid, :broadcast_played) end]},
{Soundboard.Stats, [],
[track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}
]) do
assert :ok =
PlaybackEngine.play(
"guild-1",
"channel-9",
"intro.mp3",
"/tmp/intro.mp3",
0.8,
"System"
)
assert_receive :joined_channel
assert_receive :played_sound
assert_receive :broadcast_played
refute_received :tracked_play
end
end
test "retries after stopping already-playing audio" do
test_pid = self()
attempt_ref = make_ref()
Process.put(attempt_ref, 0)
with_mocks([
{Soundboard.AudioPlayer.SoundLibrary, [],
[prepare_play_input: fn "retry.mp3", "/tmp/retry.mp3" -> {"/tmp/retry.mp3", :url} end]},
{Soundboard.Discord.Voice, [],
[
channel_id: fn "guild-1" -> "channel-9" end,
ready?: fn "guild-1" -> true end,
stop: fn "guild-1" -> send(test_pid, :stopped_audio) end,
play: fn "guild-1", "/tmp/retry.mp3", :url, [volume: 1.0] ->
case Process.get(attempt_ref, 0) do
0 ->
Process.put(attempt_ref, 1)
{:error, "Audio already playing in voice channel."}
_ ->
send(test_pid, :played_after_retry)
:ok
end
end
]},
{Soundboard.PubSubTopics, [],
[broadcast_sound_played: fn "retry.mp3", "System" -> send(test_pid, :broadcast_played) end]},
{Soundboard.Stats, [],
[track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}
]) do
assert :ok =
PlaybackEngine.play(
"guild-1",
"channel-9",
"retry.mp3",
"/tmp/retry.mp3",
1.0,
"System"
)
assert_receive :stopped_audio
assert_receive :played_after_retry
assert_receive :broadcast_played
refute_received :tracked_play
end
end
test "refreshes the current voice session after repeated encryption negotiation failures" do
test_pid = self()
attempt_ref = make_ref()
ready_ref = make_ref()
Process.put(attempt_ref, 0)
Process.put(ready_ref, 0)
with_mocks([
{Soundboard.AudioPlayer.SoundLibrary, [],
[
prepare_play_input: fn "refresh.mp3", "/tmp/refresh.mp3" ->
{"/tmp/refresh.mp3", :url}
end
]},
{Soundboard.AudioPlayer, [],
[current_voice_channel: fn -> {:ok, {"guild-1", "channel-9"}} end]},
{Soundboard.Discord.Voice, [],
[
channel_id: fn "guild-1" -> "channel-9" end,
ready?: fn "guild-1" ->
case Process.get(ready_ref, 0) do
0 ->
Process.put(ready_ref, 1)
true
1 ->
Process.put(ready_ref, 2)
false
_ ->
true
end
end,
join_channel: fn "guild-1", "channel-9" -> send(test_pid, :refreshed_voice) end,
play: fn "guild-1", "/tmp/refresh.mp3", :url, [volume: 1.0] ->
attempt = Process.get(attempt_ref, 0)
Process.put(attempt_ref, attempt + 1)
if attempt < 4 do
{:error, "Voice session is still negotiating encryption."}
else
send(test_pid, :played_after_refresh)
:ok
end
end
]},
{Soundboard.PubSubTopics, [],
[
broadcast_sound_played: fn "refresh.mp3", "System" ->
send(test_pid, :broadcast_played)
end,
broadcast_error: fn message -> flunk("unexpected playback error: #{message}") end
]},
{Soundboard.Stats, [],
[track_play: fn _sound_name, _user_id -> send(test_pid, :tracked_play) end]}
]) do
assert :ok =
PlaybackEngine.play(
"guild-1",
"channel-9",
"refresh.mp3",
"/tmp/refresh.mp3",
1.0,
"System"
)
assert_receive :refreshed_voice
assert_receive :played_after_refresh
assert_receive :broadcast_played
refute_received :tracked_play
end
end
test "returns an error when ffmpeg is unavailable" do
test_pid = self()
Application.put_env(:soundboard, :ffmpeg_executable, false)
with_mocks([
{Soundboard.Discord.Voice, [],
[
channel_id: fn "guild-1" -> "channel-9" end,
ready?: fn "guild-1" -> true end
]},
{Soundboard.PubSubTopics, [],
[
broadcast_error: fn "ffmpeg is not installed on this host" ->
send(test_pid, :broadcast_error)
end
]}
]) do
assert :error =
PlaybackEngine.play(
"guild-1",
"channel-9",
"missing-ffmpeg.mp3",
"/tmp/missing-ffmpeg.mp3",
1.0,
"System"
)
assert_receive :broadcast_error
end
end
test "logs when voice readiness times out before playback" do
log =
capture_log(fn ->
with_mocks([
{Soundboard.AudioPlayer.SoundLibrary, [],
[
prepare_play_input: fn "timeout.mp3", "/tmp/timeout.mp3" ->
{"/tmp/timeout.mp3", :url}
end
]},
{Soundboard.Discord.Voice, [],
[
channel_id: fn "guild-1" -> "channel-9" end,
ready?: fn "guild-1" -> false end,
play: fn "guild-1", "/tmp/timeout.mp3", :url, [volume: 1.0] -> :ok end
]},
{Soundboard.PubSubTopics, [],
[
broadcast_sound_played: fn _, _ -> :ok end,
broadcast_error: fn _ -> :ok end
]},
{Soundboard.Stats, [], [track_play: fn _, _ -> :ok end]}
]) do
assert :ok =
PlaybackEngine.play(
"guild-1",
"channel-9",
"timeout.mp3",
"/tmp/timeout.mp3",
1.0,
"System"
)
end
end)
assert log =~ "Timed out waiting for voice readiness in guild guild-1"
end
end
================================================
FILE: test/soundboard/audio_player/playback_queue_test.exs
================================================
defmodule Soundboard.AudioPlayer.PlaybackQueueTest do
use ExUnit.Case, async: false
import Mock
alias Soundboard.AudioPlayer.PlaybackQueue
alias Soundboard.AudioPlayer.State
defp base_state(overrides \\ []) do
struct!(
State,
Keyword.merge(
[
voice_channel: {"guild-1", "channel-9"},
current_playback: nil,
pending_request: nil,
interrupting: false,
interrupt_watchdog_ref: nil,
interrupt_watchdog_attempt: 0
],
overrides
)
)
end
defp request(overrides \\ %{}) do
Map.merge(
%{
guild_id: "guild-1",
channel_id: "channel-9",
sound_name: "intro.mp3",
path_or_url: "/tmp/intro.mp3",
volume: 0.8,
actor: "System"
},
overrides
)
end
test "build_request returns a normalized playback request" do
with_mock Soundboard.AudioPlayer.SoundLibrary,
get_sound_path: fn "intro.mp3" -> {:ok, {"/tmp/intro.mp3", 0.8}} end do
assert {:ok,
%{
guild_id: "guild-1",
channel_id: "channel-9",
sound_name: "intro.mp3",
path_or_url: "/tmp/intro.mp3",
volume: 0.8,
actor: "System"
}} = PlaybackQueue.build_request({"guild-1", "channel-9"}, "intro.mp3", "System")
end
end
test "build_request returns lookup errors unchanged" do
with_mock Soundboard.AudioPlayer.SoundLibrary,
get_sound_path: fn "missing.mp3" -> {:error, "Sound not found"} end do
assert {:error, "Sound not found"} =
PlaybackQueue.build_request({"guild-1", "channel-9"}, "missing.mp3", "System")
end
end
test "enqueue starts playback immediately when idle" do
test_pid = self()
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "intro.mp3", "/tmp/intro.mp3", 0.8, "System" ->
send(test_pid, :play_started)
:ok
end do
state = PlaybackQueue.enqueue(base_state(), request(), 35)
assert %{sound_name: "intro.mp3", task_ref: ref, task_pid: pid} = state.current_playback
assert is_reference(ref)
assert is_pid(pid)
assert state.pending_request == nil
assert state.interrupting == false
assert state.interrupt_watchdog_attempt == 0
assert_receive :play_started
PlaybackQueue.clear_all(state)
end
end
test "enqueue interrupts current playback and schedules watchdog when audio is still playing" do
test_pid = self()
current = %{guild_id: "guild-1", sound_name: "old.mp3"}
with_mocks([
{Soundboard.Discord.Voice, [],
[
stop: fn "guild-1" -> send(test_pid, :stopped_voice) end,
playing?: fn "guild-1" -> true end
]}
]) do
state = PlaybackQueue.enqueue(base_state(current_playback: current), request(), 35)
assert_receive :stopped_voice
assert state.pending_request.sound_name == "intro.mp3"
assert state.interrupting == true
assert state.interrupt_watchdog_attempt == 1
assert is_reference(state.interrupt_watchdog_ref)
end
end
test "enqueue fast-path starts pending playback when stop finishes immediately" do
test_pid = self()
current = %{guild_id: "guild-1", sound_name: "old.mp3"}
with_mocks([
{Soundboard.Discord.Voice, [],
[
stop: fn "guild-1" -> send(test_pid, :stopped_voice) end,
playing?: fn "guild-1" -> false end
]},
{Soundboard.AudioPlayer.PlaybackEngine, [],
[
play: fn "guild-1", "channel-9", "intro.mp3", "/tmp/intro.mp3", 0.8, "System" ->
send(test_pid, :play_started)
:ok
end
]}
]) do
state = PlaybackQueue.enqueue(base_state(current_playback: current), request(), 35)
assert_receive :stopped_voice
assert_receive :play_started
assert %{sound_name: "intro.mp3"} = state.current_playback
assert state.pending_request == nil
assert state.interrupting == false
PlaybackQueue.clear_all(state)
end
end
test "clear_all resets playback, pending, and interrupt state" do
timer_ref = Process.send_after(self(), :unused_watchdog, 5_000)
state =
base_state(
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request: request(%{sound_name: "next.mp3"}),
interrupting: true,
interrupt_watchdog_ref: timer_ref,
interrupt_watchdog_attempt: 4
)
|> PlaybackQueue.clear_all()
assert state.current_playback == nil
assert state.pending_request == nil
assert state.interrupting == false
assert state.interrupt_watchdog_ref == nil
assert state.interrupt_watchdog_attempt == 0
end
test "handle_task_result marks successful playback task as completed" do
current = %{
guild_id: "guild-1",
sound_name: "intro.mp3",
task_pid: self(),
task_ref: make_ref()
}
state =
base_state(current_playback: current)
|> PlaybackQueue.handle_task_result(:ok)
assert %{sound_name: "intro.mp3", task_pid: nil, task_ref: nil} = state.current_playback
end
test "handle_task_result clears failed playback and starts pending request" do
test_pid = self()
current = %{guild_id: "guild-1", sound_name: "old.mp3"}
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end do
state =
base_state(
current_playback: current,
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_task_result(:error)
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.pending_request == nil
PlaybackQueue.clear_all(state)
end
end
test "handle_task_result drops pending playback when the voice channel no longer matches" do
state =
base_state(
voice_channel: {"guild-1", "other-channel"},
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request: request(%{sound_name: "next.mp3"})
)
|> PlaybackQueue.handle_task_result(:error)
assert state.current_playback == nil
assert state.pending_request == nil
end
test "handle_task_down clears crashed playback and starts pending request" do
test_pid = self()
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end do
state =
base_state(
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_task_down(:boom)
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
PlaybackQueue.clear_all(state)
end
end
test "handle_interrupt_watchdog starts pending playback once current playback is already gone" do
test_pid = self()
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end do
state =
base_state(
interrupting: true,
interrupt_watchdog_attempt: 1,
current_playback: nil,
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_interrupt_watchdog("guild-1", 1, 3, 35)
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.interrupting == false
assert state.interrupt_watchdog_attempt == 0
PlaybackQueue.clear_all(state)
end
end
test "handle_interrupt_watchdog retries stop and reschedules when audio is still playing" do
test_pid = self()
with_mock Soundboard.Discord.Voice,
stop: fn "guild-1" -> send(test_pid, :stopped_voice) end,
playing?: fn "guild-1" -> true end do
state =
base_state(
interrupting: true,
interrupt_watchdog_attempt: 1,
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request: request()
)
|> PlaybackQueue.handle_interrupt_watchdog("guild-1", 1, 3, 35)
assert_receive :stopped_voice
assert state.current_playback.sound_name == "old.mp3"
assert state.pending_request.sound_name == "intro.mp3"
assert state.interrupting == true
assert state.interrupt_watchdog_attempt == 2
assert is_reference(state.interrupt_watchdog_ref)
end
end
test "handle_interrupt_watchdog clears current playback when audio is already stopped" do
test_pid = self()
with_mocks([
{Soundboard.Discord.Voice, [], [playing?: fn "guild-1" -> false end]},
{Soundboard.AudioPlayer.PlaybackEngine, [],
[
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end
]}
]) do
state =
base_state(
interrupting: true,
interrupt_watchdog_attempt: 1,
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_interrupt_watchdog("guild-1", 1, 3, 35)
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.interrupting == false
assert state.interrupt_watchdog_attempt == 0
PlaybackQueue.clear_all(state)
end
end
test "handle_interrupt_watchdog forces the latest request after max attempts" do
test_pid = self()
with_mocks([
{Soundboard.Discord.Voice, [], [stop: fn "guild-1" -> send(test_pid, :stopped_voice) end]},
{Soundboard.AudioPlayer.PlaybackEngine, [],
[
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end
]}
]) do
state =
base_state(
interrupting: true,
interrupt_watchdog_attempt: 3,
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_interrupt_watchdog("guild-1", 3, 3, 35)
assert_receive :stopped_voice
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.interrupting == false
assert state.interrupt_watchdog_attempt == 0
PlaybackQueue.clear_all(state)
end
end
test "handle_interrupt_watchdog is a no-op when not interrupting" do
state = base_state() |> PlaybackQueue.handle_interrupt_watchdog("guild-1", 1, 3, 35)
assert state == base_state()
end
test "handle_playback_finished clears matching playback and starts the pending request" do
test_pid = self()
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end do
state =
base_state(
current_playback: %{guild_id: "guild-1", sound_name: "old.mp3"},
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_playback_finished("guild-1")
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.pending_request == nil
PlaybackQueue.clear_all(state)
end
end
test "handle_playback_finished resumes pending playback after interrupt flow finishes" do
test_pid = self()
with_mock Soundboard.AudioPlayer.PlaybackEngine,
play: fn "guild-1", "channel-9", "next.mp3", "/tmp/next.mp3", 0.6, "System" ->
send(test_pid, :play_started)
:ok
end do
state =
base_state(
voice_channel: {"guild-1", "channel-9"},
current_playback: %{guild_id: "other-guild", sound_name: "other.mp3"},
interrupting: true,
pending_request:
request(%{sound_name: "next.mp3", path_or_url: "/tmp/next.mp3", volume: 0.6})
)
|> PlaybackQueue.handle_playback_finished("guild-1")
assert_receive :play_started
assert %{sound_name: "next.mp3"} = state.current_playback
assert state.interrupting == false
PlaybackQueue.clear_all(state)
end
end
test "handle_playback_finished ignores unrelated guilds" do
state =
base_state(current_playback: %{guild_id: "guild-1", sound_name: "intro.mp3"})
|> PlaybackQueue.handle_playback_finished("other-guild")
assert state.current_playback.sound_name == "intro.mp3"
end
end
================================================
FILE: test/soundboard/audio_player/sound_library_test.exs
================================================
defmodule Soundboard.AudioPlayer.SoundLibraryTest do
use Soundboard.DataCase
alias Soundboard.Accounts.User
alias Soundboard.AudioPlayer.SoundLibrary
alias Soundboard.{Repo, Sound}
setup do
clear_sound_cache()
on_exit(fn ->
clear_sound_cache()
end)
{:ok, user} =
%User{}
|> User.changeset(%{
username: "library_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
%{user: user}
end
test "ensure_cache/0 creates the cache table and is idempotent" do
assert :undefined == :ets.whereis(:sound_meta_cache)
assert :ok = SoundLibrary.ensure_cache()
refute :undefined == :ets.whereis(:sound_meta_cache)
assert :ok = SoundLibrary.ensure_cache()
end
test "get_sound_path/1 resolves and caches URL sounds", %{user: user} do
sound =
insert_sound!(user, %{
filename: unique_filename("remote", ".mp3"),
source_type: "url",
url: "https://example.com/wow.mp3",
volume: 0.8
})
assert {:ok, {"https://example.com/wow.mp3", 0.8}} =
SoundLibrary.get_sound_path(sound.filename)
Repo.delete!(sound)
assert {:ok, {"https://example.com/wow.mp3", 0.8}} =
SoundLibrary.get_sound_path(sound.filename)
end
test "get_sound_path/1 resolves local sounds when the file exists", %{user: user} do
filename = unique_filename("local", ".wav")
path = Soundboard.UploadsPath.file_path(filename)
File.mkdir_p!(Path.dirname(path))
File.write!(path, "audio")
on_exit(fn -> File.rm(path) end)
sound = insert_sound!(user, %{filename: filename, source_type: "local", volume: 1.2})
assert {:ok, {^path, 1.2}} = SoundLibrary.get_sound_path(sound.filename)
end
test "get_sound_path/1 returns helpful errors for missing local files", %{user: user} do
filename = unique_filename("missing", ".mp3")
sound = insert_sound!(user, %{filename: filename, source_type: "local"})
assert {:error, message} = SoundLibrary.get_sound_path(sound.filename)
assert message == "Sound file not found at #{Soundboard.UploadsPath.file_path(filename)}"
end
test "get_sound_path/1 returns error when the sound is missing" do
assert {:error, "Sound not found"} = SoundLibrary.get_sound_path("missing.mp3")
end
test "prepare_play_input/2 prefers cached source metadata", %{user: user} do
sound =
insert_sound!(user, %{
filename: unique_filename("cached", ".mp3"),
source_type: "url",
url: "https://example.com/cached.mp3"
})
assert {:ok, {"https://example.com/cached.mp3", 1.0}} =
SoundLibrary.get_sound_path(sound.filename)
assert {"play-this", :url} = SoundLibrary.prepare_play_input(sound.filename, "play-this")
end
test "prepare_play_input/2 falls back to the database when cache is empty", %{user: user} do
sound =
insert_sound!(user, %{
filename: unique_filename("db", ".mp3"),
source_type: "url",
url: "https://example.com/db.mp3"
})
assert {"from-db", :url} = SoundLibrary.prepare_play_input(sound.filename, "from-db")
end
test "invalidate_cache/1 deletes cached entries and ignores non-binary input", %{user: user} do
sound =
insert_sound!(user, %{
filename: unique_filename("invalidate", ".mp3"),
source_type: "url",
url: "https://example.com/invalidate.mp3"
})
assert {:ok, {"https://example.com/invalidate.mp3", 1.0}} =
SoundLibrary.get_sound_path(sound.filename)
assert [{_, _}] = :ets.lookup(:sound_meta_cache, sound.filename)
assert :ok = SoundLibrary.invalidate_cache(sound.filename)
assert [] == :ets.lookup(:sound_meta_cache, sound.filename)
assert :ok = SoundLibrary.invalidate_cache(nil)
end
defp insert_sound!(user, attrs) do
attrs =
attrs
|> Map.put_new(:user_id, user.id)
|> Map.put_new(:volume, 1.0)
%Sound{}
|> Sound.changeset(attrs)
|> Repo.insert!()
end
defp unique_filename(prefix, ext) do
"#{prefix}_#{System.unique_integer([:positive])}#{ext}"
end
defp clear_sound_cache do
case :ets.whereis(:sound_meta_cache) do
:undefined -> :ok
_table -> :ets.delete(:sound_meta_cache)
end
end
end
================================================
FILE: test/soundboard/discord/bot_identity_test.exs
================================================
defmodule Soundboard.Discord.BotIdentityTest do
use ExUnit.Case, async: false
import Mock
alias EDA.API.User, as: APIUser
alias EDA.Cache
alias Soundboard.Discord.BotIdentity
test "fetch/0 returns normalized cached user when available" do
with_mocks([
{Cache, [], [me: fn -> %{"id" => "cached-user"} end]},
{APIUser, [], [me: fn -> flunk("API should not be called when cache is warm") end]}
]) do
assert {:ok, %{id: "cached-user"}} = BotIdentity.fetch()
end
end
test "fetch/0 retrieves from the API and caches the user on cache miss" do
test_pid = self()
fetched_user = %{id: "api-user"}
with_mocks([
{Cache, [],
[
me: fn -> nil end,
put_me: fn ^fetched_user ->
send(test_pid, :cached_user)
:ok
end
]},
{APIUser, [], [me: fn -> {:ok, fetched_user} end]}
]) do
assert {:ok, %{id: "api-user"}} = BotIdentity.fetch()
assert_receive :cached_user
assert_called(APIUser.me())
assert_called(Cache.put_me(fetched_user))
end
end
test "fetch/0 returns non-success API responses unchanged" do
with_mocks([
{Cache, [], [me: fn -> nil end, put_me: fn _ -> :ok end]},
{APIUser, [], [me: fn -> {:error, :unavailable} end]}
]) do
assert {:error, :unavailable} = BotIdentity.fetch()
end
end
end
================================================
FILE: test/soundboard/discord/handler/auto_join_policy_test.exs
================================================
defmodule Soundboard.Discord.Handler.AutoJoinPolicyTest do
use ExUnit.Case, async: false
alias Soundboard.Discord.Handler.AutoJoinPolicy
setup do
original_env = Application.get_env(:soundboard, :env)
original_auto_join = System.get_env("AUTO_JOIN")
on_exit(fn ->
Application.put_env(:soundboard, :env, original_env)
if is_nil(original_auto_join) do
System.delete_env("AUTO_JOIN")
else
System.put_env("AUTO_JOIN", original_auto_join)
end
end)
:ok
end
test "mode/0 is :play in test environment regardless of AUTO_JOIN" do
Application.put_env(:soundboard, :env, :test)
System.put_env("AUTO_JOIN", "false")
assert AutoJoinPolicy.mode() == :play
end
test "defaults to :play when AUTO_JOIN is not set" do
Application.put_env(:soundboard, :env, :dev)
System.delete_env("AUTO_JOIN")
assert AutoJoinPolicy.mode() == :play
end
test "AUTO_JOIN=play returns :play" do
Application.put_env(:soundboard, :env, :dev)
System.put_env("AUTO_JOIN", "play")
assert AutoJoinPolicy.mode() == :play
end
test "AUTO_JOIN=presence returns :presence" do
Application.put_env(:soundboard, :env, :dev)
System.put_env("AUTO_JOIN", "presence")
assert AutoJoinPolicy.mode() == :presence
end
test "truthy values map to :presence" do
Application.put_env(:soundboard, :env, :dev)
for value <- ["true", "TRUE", " yes ", "1"] do
System.put_env("AUTO_JOIN", value)
assert AutoJoinPolicy.mode() == :presence
end
end
test "falsy and unknown values map to false" do
Application.put_env(:soundboard, :env, :dev)
for value <- ["false", "0", "no", "never", "unknown"] do
System.put_env("AUTO_JOIN", value)
assert AutoJoinPolicy.mode() == false
end
end
end
================================================
FILE: test/soundboard/discord/handler/command_handler_test.exs
================================================
defmodule Soundboard.Discord.Handler.CommandHandlerTest do
use ExUnit.Case, async: false
import Mock
alias Soundboard.Discord.Handler.CommandHandler
alias Soundboard.Discord.Handler.VoiceRuntime
alias Soundboard.Discord.Message
setup do
original_scheme = System.get_env("SCHEME")
System.delete_env("SCHEME")
on_exit(fn ->
case original_scheme do
nil -> System.delete_env("SCHEME")
value -> System.put_env("SCHEME", value)
end
end)
:ok
end
test "!join uses the endpoint URL when building the response message" do
with_mocks([
{VoiceRuntime, [],
[
user_voice_channel: fn "guild-1", "user-1" -> "voice-1" end,
join_voice_channel: fn "guild-1", "voice-1" -> :ok end
]},
{Message, [],
[
create: fn channel_id, body ->
send(self(), {:created_message, channel_id, body})
:ok
end
]}
]) do
CommandHandler.handle_message(%{
content: "!join",
guild_id: "guild-1",
channel_id: "text-1",
author: %{id: "user-1"}
})
assert_receive {:created_message, "text-1", body}
assert body =~ "Joined your voice channel!"
assert body =~ SoundboardWeb.Endpoint.url()
refute body =~ "nil://"
end
end
test "!leave leaves the current voice channel and confirms in chat" do
with_mocks([
{VoiceRuntime, [], [leave_voice_channel: fn "guild-1" -> :ok end]},
{Message, [],
[
create: fn channel_id, body ->
send(self(), {:created_message, channel_id, body})
:ok
end
]}
]) do
assert :ok =
CommandHandler.handle_message(%{
content: "!leave",
guild_id: "guild-1",
channel_id: "text-1"
})
assert_receive {:created_message, "text-1", "Left the voice channel!"}
end
end
end
================================================
FILE: test/soundboard/discord/handler/idle_timeout_policy_test.exs
================================================
defmodule Soundboard.Discord.Handler.IdleTimeoutPolicyTest do
use ExUnit.Case, async: false
alias Soundboard.Discord.Handler.IdleTimeoutPolicy
setup do
original = System.get_env("VOICE_IDLE_TIMEOUT_SECONDS")
on_exit(fn ->
if is_nil(original) do
System.delete_env("VOICE_IDLE_TIMEOUT_SECONDS")
else
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", original)
end
end)
:ok
end
test "defaults to 600 seconds (10 minutes) when env var is not set" do
System.delete_env("VOICE_IDLE_TIMEOUT_SECONDS")
assert IdleTimeoutPolicy.timeout_ms() == 600 * 1_000
end
test "reads VOICE_IDLE_TIMEOUT_SECONDS from environment" do
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", "300")
assert IdleTimeoutPolicy.timeout_ms() == 300 * 1_000
end
test "handles whitespace around the value" do
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", " 30 ")
assert IdleTimeoutPolicy.timeout_ms() == 30 * 1_000
end
test "returns nil when set to 0 (disabled)" do
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", "0")
assert IdleTimeoutPolicy.timeout_ms() == nil
end
test "returns nil when set to a negative value" do
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", "-1")
assert IdleTimeoutPolicy.timeout_ms() == nil
end
test "falls back to default when value is non-numeric" do
System.put_env("VOICE_IDLE_TIMEOUT_SECONDS", "ten")
assert IdleTimeoutPolicy.timeout_ms() == 600 * 1_000
end
end
================================================
FILE: test/soundboard/discord/handler/voice_presence_test.exs
================================================
defmodule Soundboard.Discord.Handler.VoicePresenceTest do
use ExUnit.Case, async: false
import Mock
alias Soundboard.Discord.GuildCache
alias Soundboard.Discord.Handler.VoicePresence
describe "find_user_voice_channel/1" do
test "returns {:ok, {guild_id, channel_id}} when user is in a voice channel" do
guilds = [
%{
id: "guild-1",
voice_states: [
%{user_id: "user-99", channel_id: "ch-5", guild_id: "guild-1", session_id: "s1"}
]
}
]
with_mock GuildCache, all: fn -> guilds end do
assert VoicePresence.find_user_voice_channel("user-99") == {:ok, {"guild-1", "ch-5"}}
end
end
test "returns :not_found when user is in no guild" do
guilds = [
%{
id: "guild-1",
voice_states: [
%{user_id: "other-user", channel_id: "ch-5", guild_id: "guild-1", session_id: "s1"}
]
}
]
with_mock GuildCache, all: fn -> guilds end do
assert VoicePresence.find_user_voice_channel("user-99") == :not_found
end
end
test "returns :not_found when user has no channel_id" do
guilds = [
%{
id: "guild-1",
voice_states: [
%{user_id: "user-99", channel_id: nil, guild_id: "guild-1", session_id: "s1"}
]
}
]
with_mock GuildCache, all: fn -> guilds end do
assert VoicePresence.find_user_voice_channel("user-99") == :not_found
end
end
test "searches across multiple guilds" do
guilds = [
%{id: "guild-1", voice_states: []},
%{
id: "guild-2",
voice_states: [
%{user_id: "user-99", channel_id: "ch-7", guild_id: "guild-2", session_id: "s2"}
]
}
]
with_mock GuildCache, all: fn -> guilds end do
assert VoicePresence.find_user_voice_channel("user-99") == {:ok, {"guild-2", "ch-7"}}
end
end
test "returns :not_found when guild cache is unavailable" do
with_mock GuildCache, all: fn -> raise "cache unavailable" end do
assert VoicePresence.find_user_voice_channel("user-99") == :not_found
end
end
end
end
================================================
FILE: test/soundboard/discord/handler/voice_runtime_test.exs
================================================
defmodule Soundboard.Discord.Handler.VoiceRuntimeTest do
use ExUnit.Case, async: false
import ExUnit.CaptureLog
import Mock
alias Soundboard.AudioPlayer
alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoicePresence, VoiceRuntime}
test "handle_disconnect returns a recheck action instead of scheduling directly" do
payload = %{guild_id: "guild-1", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :presence end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end,
users_in_channel: fn "guild-1", "channel-1" -> {:ok, 2} end
]}
]) do
assert VoiceRuntime.handle_disconnect(payload) == [
{:schedule_recheck_alone, "guild-1", "channel-1", 1_500}
]
end
end
test "handle_connect returns a recheck action when the bot is still sharing another channel" do
payload = %{guild_id: "guild-1", channel_id: "channel-2", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :presence end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end,
users_in_channel: fn "guild-1", "channel-1" -> {:ok, 3} end
]}
]) do
assert VoiceRuntime.handle_connect(payload) == [
{:schedule_recheck_alone, "guild-1", "channel-1", 1_500}
]
end
end
test "recheck_alone logs and returns no actions when voice state is unavailable" do
log =
capture_log(fn ->
with_mocks([
{VoicePresence, [],
[
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end,
users_in_channel: fn "guild-1", "channel-1" -> {:error, :unavailable} end
]}
]) do
assert VoiceRuntime.recheck_alone("guild-1", "channel-1") == []
end
end)
assert log =~ "Recheck skipped because voice state was unavailable"
end
test "bootstrap scans cached guilds and joins an active voice channel" do
test_pid = self()
guild = %{
id: "guild-1",
voice_states: [
%{user_id: "user-1", channel_id: nil},
%{user_id: "user-2", channel_id: "channel-9"}
]
}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :presence end]},
{VoicePresence, [],
[cached_guilds: fn -> {:ok, [guild]} end, bot_id: fn -> {:ok, "bot-1"} end]},
{Soundboard.Discord.Voice, [],
[join_channel: fn "guild-1", "channel-9" -> send(test_pid, :joined_bootstrap_channel) end]},
{AudioPlayer, [],
[set_voice_channel: fn "guild-1", "channel-9" -> send(test_pid, :set_bootstrap_voice) end]}
]) do
assert :ok = VoiceRuntime.bootstrap()
assert_receive :joined_bootstrap_channel, 6_000
assert_receive :set_bootstrap_voice, 1_000
end
end
test "bootstrap skips guild check in play mode" do
with_mock AutoJoinPolicy, mode: fn -> :play end do
assert :ok = VoiceRuntime.bootstrap()
# No Task spawned, nothing to assert — just verify it returns :ok without hanging
end
end
test "handle_disconnect notifies AudioPlayer when bot is alone in play mode" do
test_pid = self()
payload = %{guild_id: "guild-1", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :play end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end,
users_in_channel: fn "guild-1", "channel-1" -> {:ok, 0} end
]},
{AudioPlayer, [],
[
last_user_left: fn "guild-1" ->
send(test_pid, :last_user_left_called)
end
]}
]) do
VoiceRuntime.handle_disconnect(payload)
assert_receive :last_user_left_called, 1_000
end
end
test "handle_disconnect notifies AudioPlayer when bot is alone in false mode" do
test_pid = self()
payload = %{guild_id: "guild-1", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end,
users_in_channel: fn "guild-1", "channel-1" -> {:ok, 0} end
]},
{AudioPlayer, [],
[
last_user_left: fn "guild-1" ->
send(test_pid, :last_user_left_called)
end
]}
]) do
VoiceRuntime.handle_disconnect(payload)
assert_receive :last_user_left_called, 1_000
end
end
test "handle_connect in false mode cancels idle timer when user joins bot's channel" do
test_pid = self()
payload = %{guild_id: "guild-1", channel_id: "channel-1", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end
]},
{AudioPlayer, [],
[
user_joined_channel: fn "guild-1" ->
send(test_pid, :user_joined_called)
end
]}
]) do
VoiceRuntime.handle_connect(payload)
assert_receive :user_joined_called, 1_000
end
end
test "handle_connect in false mode ignores joins to other channels" do
payload = %{guild_id: "guild-1", channel_id: "channel-2", user_id: "user-1"}
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{VoicePresence, [],
[
bot_user?: fn "user-1" -> false end,
current_voice_channel: fn -> {:ok, {"guild-1", "channel-1"}} end
]},
{AudioPlayer, [], [user_joined_channel: fn _ -> :ok end]}
]) do
VoiceRuntime.handle_connect(payload)
refute called(AudioPlayer.user_joined_channel(:_))
end
end
test "handle_disconnect ignores the bot's own voice disconnect" do
payload = %{guild_id: "guild-1", user_id: "bot-999"}
with_mocks([
{VoicePresence, [], [bot_user?: fn "bot-999" -> true end]},
{AudioPlayer, [], [last_user_left: fn _ -> :ok end]}
]) do
assert VoiceRuntime.handle_disconnect(payload) == []
refute called(AudioPlayer.last_user_left(:_))
end
end
test "handle_connect returns [] in play mode" do
payload = %{guild_id: "guild-1", channel_id: "channel-1", user_id: "user-1"}
with_mock AutoJoinPolicy, mode: fn -> :play end do
assert VoiceRuntime.handle_connect(payload) == []
end
end
end
================================================
FILE: test/soundboard/discord/role_checker_test.exs
================================================
defmodule Soundboard.Discord.RoleCheckerTest do
use ExUnit.Case, async: false
import Mock
alias EDA.API.Member
alias Soundboard.Discord.RoleChecker
setup do
previous_guild = Application.get_env(:soundboard, :required_guild_id)
previous_roles = Application.get_env(:soundboard, :required_role_ids)
on_exit(fn ->
restore_env(:required_guild_id, previous_guild)
restore_env(:required_role_ids, previous_roles || [])
end)
:ok
end
defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)
defp restore_env(key, value), do: Application.put_env(:soundboard, key, value)
describe "feature_enabled?/0" do
test "returns false when guild_id is missing" do
Application.put_env(:soundboard, :required_guild_id, nil)
Application.put_env(:soundboard, :required_role_ids, ["r1"])
refute RoleChecker.feature_enabled?()
end
test "returns false when role_ids is empty" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, [])
refute RoleChecker.feature_enabled?()
end
test "returns true when both guild_id and role_ids are configured" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
assert RoleChecker.feature_enabled?()
end
end
describe "authorized?/1" do
test "returns true when feature is disabled and does not call the API" do
Application.put_env(:soundboard, :required_guild_id, nil)
Application.put_env(:soundboard, :required_role_ids, [])
with_mock Member, get: fn _, _ -> flunk("API should not be called when disabled") end do
assert RoleChecker.authorized?("user1")
end
end
test "returns true when member has at least one required role" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1", "r2"])
with_mock Member,
get: fn "g1", "user1" -> {:ok, %{"roles" => ["other", "r2"]}} end do
assert RoleChecker.authorized?("user1")
assert_called(Member.get("g1", "user1"))
end
end
test "returns false when member has none of the required roles" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
with_mock Member,
get: fn "g1", "user1" -> {:ok, %{"roles" => ["other_role"]}} end do
refute RoleChecker.authorized?("user1")
end
end
test "returns false when API returns an error" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
with_mock Member, get: fn _, _ -> {:error, :not_found} end do
refute RoleChecker.authorized?("user1")
end
end
test "returns false when API response shape is unexpected" do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
with_mock Member, get: fn _, _ -> {:ok, %{"unexpected" => "shape"}} end do
refute RoleChecker.authorized?("user1")
end
end
end
end
================================================
FILE: test/soundboard/discord/runtime_capability_test.exs
================================================
defmodule Soundboard.Discord.RuntimeCapabilityTest do
use ExUnit.Case, async: false
alias Soundboard.Discord.RuntimeCapability
setup do
original_env = Application.get_env(:soundboard, :env)
original_dave = Application.get_env(:eda, :dave)
on_exit(fn ->
Application.put_env(:soundboard, :env, original_env)
Application.put_env(:eda, :dave, original_dave)
end)
:ok
end
test "voice runtime is always available in test" do
Application.put_env(:soundboard, :env, :test)
Application.put_env(:eda, :dave, true)
assert :ok = RuntimeCapability.voice_runtime_status()
refute RuntimeCapability.discord_handler_enabled?()
end
test "voice runtime is available when dave is disabled" do
Application.put_env(:soundboard, :env, :dev)
Application.put_env(:eda, :dave, false)
assert :ok = RuntimeCapability.voice_runtime_status()
assert RuntimeCapability.discord_handler_enabled?()
end
end
================================================
FILE: test/soundboard/discord/voice_test.exs
================================================
defmodule Soundboard.Discord.VoiceTest do
use ExUnit.Case, async: false
alias Soundboard.Discord.Voice
defmodule VoiceModuleWithPlay4 do
def play(guild_id, input, type, opts) do
Process.put(:voice_play_args, {guild_id, input, type, opts})
:ok
end
end
defmodule VoiceModuleWithPlay3 do
def play(guild_id, input, type) do
Process.put(:voice_play_args, {guild_id, input, type})
:ok
end
end
setup do
previous_module = Application.get_env(:soundboard, :eda_voice_module)
Process.delete(:voice_play_args)
on_exit(fn ->
Process.delete(:voice_play_args)
if is_nil(previous_module) do
Application.delete_env(:soundboard, :eda_voice_module)
else
Application.put_env(:soundboard, :eda_voice_module, previous_module)
end
end)
:ok
end
test "uses play/4 when the configured voice module supports it" do
Application.put_env(:soundboard, :eda_voice_module, VoiceModuleWithPlay4)
assert :ok = Voice.play(123, "file.mp3", :url, volume: 1.2)
assert Process.get(:voice_play_args) == {"123", "file.mp3", :url, [volume: 1.2]}
end
test "falls back to play/3 and drops opts when only play/3 is available" do
Application.put_env(:soundboard, :eda_voice_module, VoiceModuleWithPlay3)
assert :ok = Voice.play(123, "file.mp3", :url, volume: 1.2)
assert Process.get(:voice_play_args) == {"123", "file.mp3", :url}
end
end
================================================
FILE: test/soundboard/favorites_test.exs
================================================
defmodule Soundboard.FavoritesTest do
@moduledoc """
Test for the Favorites module.
"""
use Soundboard.DataCase
alias Soundboard.{Accounts.User, Favorites, Sound}
describe "favorites" do
setup do
user = insert_user()
sound = insert_sound(user)
%{user: user, sound: sound}
end
test "list_favorites/1 returns all favorites for a user", %{user: user, sound: sound} do
{:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)
assert [sound.id] == Favorites.list_favorites(user.id)
end
test "toggle_favorite/2 adds a favorite when it doesn't exist", %{user: user, sound: sound} do
assert {:ok, favorite} = Favorites.toggle_favorite(user.id, sound.id)
assert favorite.user_id == user.id
assert favorite.sound_id == sound.id
end
test "toggle_favorite/2 removes a favorite when it exists", %{user: user, sound: sound} do
{:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)
{:ok, deleted_favorite} = Favorites.toggle_favorite(user.id, sound.id)
assert deleted_favorite.__meta__.state == :deleted
assert [] == Favorites.list_favorites(user.id)
end
test "favorite?/2 returns true when favorite exists", %{user: user, sound: sound} do
refute Favorites.favorite?(user.id, sound.id)
{:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)
assert Favorites.favorite?(user.id, sound.id)
end
test "max_favorites/0 returns the maximum number of favorites allowed" do
assert Favorites.max_favorites() == 16
end
test "cannot add more favorites than max_favorites", %{user: user} do
# Create max_favorites + 1 number of sounds
sounds = Enum.map(1..(Favorites.max_favorites() + 1), fn _ -> insert_sound(user) end)
# Add max_favorites successfully
Enum.each(Enum.take(sounds, Favorites.max_favorites()), fn sound ->
assert {:ok, _} = Favorites.toggle_favorite(user.id, sound.id)
end)
# Try to add one more favorite - should fail
last_sound = List.last(sounds)
assert {:error, changeset} = Favorites.toggle_favorite(user.id, last_sound.id)
assert "You can only have 16 favorites" in errors_on(changeset).base
end
test "toggle_favorite/2 rejects missing sounds", %{user: user} do
assert {:error, changeset} = Favorites.toggle_favorite(user.id, -1)
assert "does not exist" in errors_on(changeset).sound
end
end
# Helper functions
defp insert_user(attrs \\ %{}) do
{:ok, user} =
%User{}
|> User.changeset(
Map.merge(
%{
username: "testuser",
discord_id: "123456789",
avatar: "test_avatar.jpg"
},
attrs
)
)
|> Repo.insert()
user
end
defp insert_sound(user, attrs \\ %{}) do
{:ok, sound} =
%Sound{}
|> Sound.changeset(
Map.merge(
%{
filename: "test_sound#{System.unique_integer()}.mp3",
source_type: "local",
user_id: user.id
},
attrs
)
)
|> Repo.insert()
sound
end
end
================================================
FILE: test/soundboard/migrations/data_migrations_test.exs
================================================
for migration_file <- [
"20250101213201_create_sounds.exs",
"20250101213717_create_tags.exs",
"20250101231744_create_users.exs",
"20250102212120_create_plays.exs",
"20250102212121_create_favorites.exs",
"20250102212122_add_user_id_to_sounds.exs",
"20250102212123_change_favorites_filename_to_sound_id.exs",
"20260306150000_add_sound_id_to_plays.exs",
"20260306151000_finalize_favorites_and_sound_tags_migrations.exs",
"20260307211000_rename_sound_name_to_played_filename_in_plays.exs"
] do
Code.require_file(Path.expand("../../../priv/repo/migrations/#{migration_file}", __DIR__))
end
defmodule Soundboard.Migrations.DataMigrationsTest do
use ExUnit.Case, async: false
alias Soundboard.Repo.Migrations.{
AddSoundIdToPlays,
AddUserIdToSounds,
ChangeFavoritesFilenameToSoundId,
CreateFavorites,
CreatePlays,
CreateSounds,
CreateTags,
CreateUsers,
FinalizeFavoritesAndSoundTagsMigrations,
RenameSoundNameToPlayedFilenameInPlays
}
defmodule MigrationRepo do
use Ecto.Repo,
otp_app: :soundboard,
adapter: Ecto.Adapters.SQLite3
end
setup do
db_path =
Path.join(
System.tmp_dir!(),
"soundboard-migration-#{System.unique_integer([:positive])}.db"
)
{:ok, pid} = MigrationRepo.start_link(database: db_path, pool_size: 1, name: nil)
previous_repo = MigrationRepo.put_dynamic_repo(pid)
on_exit(fn ->
MigrationRepo.put_dynamic_repo(previous_repo)
Process.exit(pid, :normal)
File.rm(db_path)
end)
%{repo: MigrationRepo}
end
test "add_sound_id_to_plays backfills matching sound ids and rolls back cleanly", %{repo: repo} do
migrate_up(repo, [
{20_250_101_213_201, CreateSounds},
{20_250_101_231_744, CreateUsers},
{20_250_102_212_120, CreatePlays},
{20_250_102_212_122, AddUserIdToSounds}
])
repo.query!("""
INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)
VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)
VALUES (1, 'beep.mp3', '[]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO plays (id, sound_name, user_id, inserted_at, updated_at)
VALUES (1, 'beep.mp3', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
:ok = Ecto.Migrator.up(repo, 20_260_306_150_000, AddSoundIdToPlays, log: false)
assert column_names(repo, "plays") |> Enum.member?("sound_id")
assert [[1]] = repo.query!("SELECT sound_id FROM plays WHERE id = 1").rows
:ok = Ecto.Migrator.down(repo, 20_260_306_150_000, AddSoundIdToPlays, log: false)
refute column_names(repo, "plays") |> Enum.member?("sound_id")
end
test "rename_sound_name_to_played_filename_in_plays renames the column and rolls back cleanly",
%{repo: repo} do
migrate_up(repo, [
{20_250_101_213_201, CreateSounds},
{20_250_101_231_744, CreateUsers},
{20_250_102_212_120, CreatePlays},
{20_250_102_212_122, AddUserIdToSounds},
{20_260_306_150_000, AddSoundIdToPlays}
])
repo.query!("""
INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)
VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)
VALUES (1, 'beep.mp3', '[]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO plays (id, sound_name, user_id, inserted_at, updated_at)
VALUES (1, 'beep.mp3', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
:ok =
Ecto.Migrator.up(
repo,
20_260_307_211_000,
RenameSoundNameToPlayedFilenameInPlays,
log: false
)
assert column_names(repo, "plays") |> Enum.member?("played_filename")
refute column_names(repo, "plays") |> Enum.member?("sound_name")
assert [["beep.mp3", 1]] =
repo.query!("SELECT played_filename, sound_id FROM plays WHERE id = 1").rows
:ok =
Ecto.Migrator.down(
repo,
20_260_307_211_000,
RenameSoundNameToPlayedFilenameInPlays,
log: false
)
assert column_names(repo, "plays") |> Enum.member?("sound_name")
refute column_names(repo, "plays") |> Enum.member?("played_filename")
end
test "finalize favorites and sound tags backfills legacy tags and restores them on rollback", %{
repo: repo
} do
migrate_up(repo, [
{20_250_101_213_201, CreateSounds},
{20_250_101_213_717, CreateTags},
{20_250_101_231_744, CreateUsers},
{20_250_102_212_121, CreateFavorites},
{20_250_102_212_122, AddUserIdToSounds},
{20_250_102_212_123, ChangeFavoritesFilenameToSoundId}
])
repo.query!("""
INSERT INTO users (id, discord_id, username, avatar, inserted_at, updated_at)
VALUES (1, 'discord-1', 'tester', 'avatar.png', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO sounds (id, filename, tags, description, user_id, inserted_at, updated_at)
VALUES (1, 'beep.mp3', '[" meme ","MEME","alert",""]', NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO tags (id, name, inserted_at, updated_at)
VALUES (1, 'meme', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at)
VALUES (1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
repo.query!("""
INSERT INTO favorites (user_id, sound_id, inserted_at, updated_at)
VALUES (1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(1, 999, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
:ok =
Ecto.Migrator.up(
repo,
20_260_306_151_000,
FinalizeFavoritesAndSoundTagsMigrations,
log: false
)
refute column_names(repo, "sounds") |> Enum.member?("tags")
assert [["alert"], ["meme"]] =
repo.query!("SELECT name FROM tags ORDER BY name").rows
assert [[1, 1]] =
repo.query!("SELECT user_id, sound_id FROM favorites ORDER BY sound_id").rows
assert [[1, 1], [1, 2]] =
repo.query!("SELECT sound_id, tag_id FROM sound_tags ORDER BY tag_id").rows
:ok =
Ecto.Migrator.down(
repo,
20_260_306_151_000,
FinalizeFavoritesAndSoundTagsMigrations,
log: false
)
assert column_names(repo, "sounds") |> Enum.member?("tags")
assert [["[\"alert\",\"meme\"]"]] = repo.query!("SELECT tags FROM sounds WHERE id = 1").rows
end
defp migrate_up(repo, migrations) do
Enum.each(migrations, fn {version, migration} ->
:ok = Ecto.Migrator.up(repo, version, migration, log: false)
end)
end
defp column_names(repo, table_name) do
repo.query!("PRAGMA table_info(#{table_name})")
|> Map.fetch!(:rows)
|> Enum.map(fn [_cid, name | _rest] -> name end)
end
end
================================================
FILE: test/soundboard/public_url_test.exs
================================================
defmodule Soundboard.PublicURLTest do
use ExUnit.Case, async: false
alias Soundboard.PublicURL
test "current/0 returns the endpoint base URL" do
assert PublicURL.current() == SoundboardWeb.Endpoint.url()
end
test "from_uri_or_current/1 keeps the request host and strips default ports" do
assert PublicURL.from_uri_or_current("https://soundboard.example:443/settings") ==
"https://soundboard.example"
assert PublicURL.from_uri_or_current("http://localhost:80/settings") ==
"http://localhost"
end
test "from_uri_or_current/1 preserves non-default ports" do
assert PublicURL.from_uri_or_current("http://localhost:4000/settings") ==
"http://localhost:4000"
end
test "from_uri_or_current/1 falls back to the configured public URL for invalid input" do
assert PublicURL.from_uri_or_current(nil) == SoundboardWeb.Endpoint.url()
assert PublicURL.from_uri_or_current("not a uri") == SoundboardWeb.Endpoint.url()
end
end
================================================
FILE: test/soundboard/pubsub_topics_test.exs
================================================
defmodule Soundboard.PubSubTopicsTest do
use ExUnit.Case, async: false
alias Soundboard.PubSubTopics
test "exposes canonical topic names" do
assert PubSubTopics.files_topic() == "soundboard.files"
assert PubSubTopics.playback_topic() == "soundboard.playback"
assert PubSubTopics.stats_topic() == "soundboard.stats"
end
test "broadcast helpers publish to subscribed topics" do
PubSubTopics.subscribe_files()
PubSubTopics.subscribe_playback()
PubSubTopics.subscribe_stats()
assert :ok = PubSubTopics.broadcast_files_updated()
assert_receive {:files_updated}
assert :ok = PubSubTopics.broadcast_sound_played("wow.mp3", "tester")
assert_receive {:sound_played, %{filename: "wow.mp3", played_by: "tester"}}
assert :ok = PubSubTopics.broadcast_error("boom")
assert_receive {:error, "boom"}
assert :ok = PubSubTopics.broadcast_stats_updated()
assert_receive {:stats_updated}
end
end
================================================
FILE: test/soundboard/sound_tag_test.exs
================================================
defmodule Soundboard.SoundTagTest do
@moduledoc """
Test for the SoundTag module.
"""
use Soundboard.DataCase
alias Soundboard.Accounts.User
alias Soundboard.{Sound, SoundTag, Tag}
describe "sound_tags" do
test "changeset with valid attributes" do
sound = insert_sound()
tag = insert_tag()
attrs = %{
sound_id: sound.id,
tag_id: tag.id
}
changeset = SoundTag.changeset(%SoundTag{}, attrs)
assert changeset.valid?
assert {:ok, sound_tag} = Repo.insert(changeset)
assert sound_tag.sound_id == sound.id
assert sound_tag.tag_id == tag.id
end
test "changeset enforces unique constraint" do
sound = insert_sound()
tag = insert_tag()
attrs = %{sound_id: sound.id, tag_id: tag.id}
# First insert succeeds
{:ok, _} = Repo.insert(SoundTag.changeset(%SoundTag{}, attrs))
changeset = SoundTag.changeset(%SoundTag{}, attrs)
{:error, changeset} = Repo.insert(changeset)
assert {"has already been taken", _} = changeset.errors[:sound_id]
end
test "changeset requires sound_id and tag_id" do
changeset = SoundTag.changeset(%SoundTag{}, %{})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).sound_id
assert "can't be blank" in errors_on(changeset).tag_id
end
end
# Helper functions
defp insert_sound do
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test_sound#{System.unique_integer()}.mp3",
source_type: "local",
user_id: insert_user().id
})
|> Repo.insert()
sound
end
defp insert_tag do
{:ok, tag} =
%Tag{}
|> Tag.changeset(%{name: "test_tag#{System.unique_integer()}"})
|> Repo.insert()
tag
end
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser#{System.unique_integer()}",
discord_id: "123456789",
avatar: "test_avatar.jpg"
})
|> Repo.insert()
user
end
end
================================================
FILE: test/soundboard/sound_test.exs
================================================
defmodule Soundboard.SoundTest do
@moduledoc """
Tests the Sound module.
"""
use Soundboard.DataCase
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound, Sounds, Tag, UserSoundSetting}
describe "changeset validation" do
test "validates required fields" do
changeset = Sound.changeset(%Sound{}, %{})
assert errors_on(changeset) == %{
filename: ["can't be blank"],
user_id: ["can't be blank"]
}
end
test "validates local sound requires filename" do
changeset =
Sound.changeset(%Sound{}, %{
user_id: 1,
source_type: "local"
})
assert "can't be blank" in errors_on(changeset).filename
end
test "validates url sound requires url" do
changeset =
Sound.changeset(%Sound{}, %{
user_id: 1,
source_type: "url"
})
assert "can't be blank" in errors_on(changeset).url
end
test "validates source type values" do
changeset =
Sound.changeset(%Sound{}, %{
user_id: 1,
source_type: "invalid"
})
assert "must be either 'local' or 'url'" in errors_on(changeset).source_type
end
test "enforces unique filenames" do
user = insert_user()
attrs = %{filename: "test.mp3", source_type: "local", user_id: user.id}
{:ok, _} = %Sound{} |> Sound.changeset(attrs) |> Repo.insert()
{:error, changeset} = %Sound{} |> Sound.changeset(attrs) |> Repo.insert()
assert "has already been taken" in errors_on(changeset).filename
end
test "validates volume between 0 and 1.5" do
user = insert_user()
high_changeset =
Sound.changeset(%Sound{}, %{
filename: "loud.mp3",
source_type: "local",
user_id: user.id,
volume: 1.6
})
assert Enum.any?(
errors_on(high_changeset).volume,
&String.contains?(&1, "less than or equal")
)
low_changeset =
Sound.changeset(%Sound{}, %{
filename: "quiet.mp3",
source_type: "local",
user_id: user.id,
volume: -0.1
})
assert Enum.any?(
errors_on(low_changeset).volume,
&String.contains?(&1, "greater than or equal")
)
end
end
setup do
user = insert_user()
{:ok, tag} = %Tag{name: "test_tag"} |> Tag.changeset(%{}) |> Repo.insert()
{:ok, sound} = insert_sound(user)
%{user: user, sound: sound, tag: tag}
end
describe "tag associations" do
test "can associate tags through changeset", %{user: user, tag: tag} do
attrs = %{
filename: "test_sound_new.mp3",
source_type: "local",
user_id: user.id
}
{:ok, sound} =
%Sound{}
|> Sound.changeset(attrs)
|> Repo.insert()
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
sound = Repo.preload(sound, :tags)
assert [%{name: "test_tag"}] = sound.tags
end
end
describe "queries" do
test "with_tags/1 preloads tags", %{sound: sound, tag: tag} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
result = Sound.with_tags() |> Repo.all() |> Enum.find(&(&1.id == sound.id))
assert [%{name: "test_tag"}] = result.tags
end
test "by_tag/2 filters sounds by tag name", %{sound: sound, tag: tag} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
results = Sound.by_tag("test_tag") |> Repo.all()
assert length(results) == 1
assert hd(results).id == sound.id
end
test "list_files/0 returns all sounds with tags and settings", %{sound: sound, tag: tag} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
result = Sounds.list_files() |> Enum.find(&(&1.id == sound.id))
assert result.id == sound.id
assert [%{name: "test_tag"}] = result.tags
end
test "get_sound!/1 loads all associations", %{sound: sound, tag: tag} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
result = Sounds.get_sound!(sound.id)
assert result.id == sound.id
assert [%{name: "test_tag"}] = result.tags
end
end
describe "user sound settings" do
test "can set join sound without affecting leave sound", %{user: user} do
# Create two sounds
{:ok, sound1} = insert_sound(user)
{:ok, sound2} = insert_sound(user)
# Set sound1 as both join and leave sound
{:ok, setting1} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound1.id,
is_join_sound: true,
is_leave_sound: true
}
)
|> Repo.insert()
# Set sound2 as join sound (should only unset sound1's join sound)
:ok = UserSoundSetting.clear_conflicting_settings(user.id, sound2.id, true, false)
{:ok, setting2} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound2.id,
is_join_sound: true,
is_leave_sound: false
}
)
|> Repo.insert()
# Reload settings to verify state
setting1 = Repo.get(UserSoundSetting, setting1.id)
setting2 = Repo.get(UserSoundSetting, setting2.id)
# Original sound should keep leave sound but lose join sound
assert setting1.is_join_sound == false
assert setting1.is_leave_sound == true
# New sound should be join sound only
assert setting2.is_join_sound == true
assert setting2.is_leave_sound == false
end
test "can set leave sound without affecting join sound", %{user: user} do
# Create two sounds
{:ok, sound1} = insert_sound(user)
{:ok, sound2} = insert_sound(user)
# Set sound1 as both join and leave sound
{:ok, setting1} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound1.id,
is_join_sound: true,
is_leave_sound: true
}
)
|> Repo.insert()
# Set sound2 as leave sound (should only unset sound1's leave sound)
:ok = UserSoundSetting.clear_conflicting_settings(user.id, sound2.id, false, true)
{:ok, setting2} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound2.id,
is_join_sound: false,
is_leave_sound: true
}
)
|> Repo.insert()
# Reload settings to verify state
setting1 = Repo.get(UserSoundSetting, setting1.id)
setting2 = Repo.get(UserSoundSetting, setting2.id)
# Original sound should keep join sound but lose leave sound
assert setting1.is_join_sound == true
assert setting1.is_leave_sound == false
# New sound should be leave sound only
assert setting2.is_join_sound == false
assert setting2.is_leave_sound == true
end
test "can unset join/leave sounds independently", %{user: user} do
{:ok, sound} = insert_sound(user)
{:ok, setting} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true,
is_leave_sound: true
}
)
|> Repo.insert()
# Unset join sound only
{:ok, updated_setting} =
UserSoundSetting.changeset(
setting,
%{is_join_sound: false}
)
|> Repo.update()
# Verify leave sound remains set
assert updated_setting.is_join_sound == false
assert updated_setting.is_leave_sound == true
end
end
describe "fetch_sound_id/1" do
test "returns sound id when sound exists", %{sound: sound} do
assert Sounds.fetch_sound_id(sound.filename) == {:ok, sound.id}
end
test "returns :error when sound doesn't exist" do
assert Sounds.fetch_sound_id("nonexistent.mp3") == :error
end
end
describe "fetch_filename_extension/1" do
test "returns the stored file extension", %{sound: sound} do
assert Sounds.fetch_filename_extension(sound.id) == {:ok, ".mp3"}
end
test "returns :error when sound doesn't exist" do
assert Sounds.fetch_filename_extension(-1) == :error
end
end
describe "get_recent_uploads/1" do
test "returns recent uploads with default limit", %{user: user} do
# Create multiple sounds
_sounds =
for _i <- 1..12 do
{:ok, sound} = insert_sound(user)
sound
end
results = Sounds.get_recent_uploads()
assert length(results) >= 10
{filename, username, timestamp} = hd(results)
assert is_binary(filename)
assert is_binary(username)
assert %NaiveDateTime{} = timestamp
user_results = Enum.filter(results, fn {_, uname, _} -> uname == user.username end)
assert user_results != []
end
test "returns recent uploads with custom limit", %{user: user} do
# Create 5 sounds
for _ <- 1..5, do: insert_sound(user)
results = Sounds.get_recent_uploads(limit: 3)
assert length(results) == 3
end
test "returns empty list when no sounds exist" do
# Delete all sounds
Repo.delete_all(Sound)
results = Sounds.get_recent_uploads()
assert results == []
end
end
describe "update_sound/2" do
test "updates sound attributes", %{sound: sound, tag: tag} do
# Preload tags to avoid association error
sound = Repo.preload(sound, :tags)
attrs = %{
description: "Updated description",
tags: [tag]
}
{:ok, updated_sound} = Sounds.update_sound(sound, attrs)
assert updated_sound.description == "Updated description"
assert length(updated_sound.tags) == 1
assert hd(updated_sound.tags).id == tag.id
end
test "validates on update", %{sound: sound} do
attrs = %{source_type: "invalid"}
{:error, changeset} = Sounds.update_sound(sound, attrs)
assert "must be either 'local' or 'url'" in errors_on(changeset).source_type
end
end
describe "user join/leave sounds" do
test "get_user_join_sound/1 returns join sound filename", %{user: user, sound: sound} do
# Create join sound setting
{:ok, _} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true,
is_leave_sound: false
}
)
|> Repo.insert()
assert Sounds.get_user_join_sound(user.id) == sound.filename
end
test "get_user_join_sound/1 returns nil when no join sound", %{user: user} do
assert Sounds.get_user_join_sound(user.id) == nil
end
test "get_user_leave_sound/1 returns leave sound filename", %{user: user, sound: sound} do
# Create leave sound setting
{:ok, _} =
UserSoundSetting.changeset(
%UserSoundSetting{},
%{
user_id: user.id,
sound_id: sound.id,
is_join_sound: false,
is_leave_sound: true
}
)
|> Repo.insert()
assert Sounds.get_user_leave_sound(user.id) == sound.filename
end
test "get_user_leave_sound/1 returns nil when no leave sound", %{user: user} do
assert Sounds.get_user_leave_sound(user.id) == nil
end
end
describe "get_user_sound_preferences_by_discord_id/1" do
test "returns both join and leave sounds without assuming they share one row", %{user: user} do
{:ok, join_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "join_#{System.unique_integer([:positive])}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
{:ok, leave_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "leave_#{System.unique_integer([:positive])}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: join_sound.id,
is_join_sound: true,
is_leave_sound: false
})
|> Repo.insert!()
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: leave_sound.id,
is_join_sound: false,
is_leave_sound: true
})
|> Repo.insert!()
user_id = user.id
assert %{user_id: ^user_id, join_sound: join_filename, leave_sound: leave_filename} =
Sounds.get_user_sound_preferences_by_discord_id(user.discord_id)
assert join_filename == join_sound.filename
assert leave_filename == leave_sound.filename
end
test "returns user preferences with nil sounds when no join/leave sounds", %{user: user} do
user_id = user.id
assert %{user_id: ^user_id, join_sound: nil, leave_sound: nil} =
Sounds.get_user_sound_preferences_by_discord_id(user.discord_id)
end
test "returns nil when user doesn't exist" do
assert Sounds.get_user_sound_preferences_by_discord_id("nonexistent_discord_id") == nil
end
end
describe "changeset with tags" do
test "associates tags when provided in attrs", %{user: user, tag: tag} do
attrs = %{
filename: "tagged_sound.mp3",
source_type: "local",
user_id: user.id,
tags: [tag]
}
changeset = Sound.changeset(%Sound{}, attrs)
assert changeset.valid?
{:ok, sound} = Repo.insert(changeset)
sound = Repo.preload(sound, :tags)
assert length(sound.tags) == 1
assert hd(sound.tags).id == tag.id
end
test "handles empty tags list", %{user: user} do
attrs = %{
filename: "no_tags_sound.mp3",
source_type: "local",
user_id: user.id,
tags: []
}
changeset = Sound.changeset(%Sound{}, attrs)
assert changeset.valid?
{:ok, sound} = Repo.insert(changeset)
sound = Repo.preload(sound, :tags)
assert sound.tags == []
end
end
describe "repo persistence" do
test "can rename sound", %{sound: sound} do
{:ok, updated_sound} =
Sound.changeset(sound, %{filename: "renamed_sound.mp3"})
|> Repo.update()
assert updated_sound.filename == "renamed_sound.mp3"
assert updated_sound.id == sound.id
end
test "owner can delete sound", %{sound: sound} do
assert {:ok, _} = Repo.delete(sound)
refute Repo.get(Sound, sound.id)
end
end
# Helper functions
defp insert_user do
{:ok, user} =
%Soundboard.Accounts.User{}
|> User.changeset(%{
username: "test_user_#{System.unique_integer()}",
discord_id: "123456_#{System.unique_integer()}",
avatar: "test.jpg"
})
|> Repo.insert()
user
end
defp insert_sound(user) do
%Sound{}
|> Sound.changeset(%{
filename: "test_sound_#{System.unique_integer()}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
end
end
================================================
FILE: test/soundboard/sounds/management_test.exs
================================================
defmodule Soundboard.Sounds.ManagementTest do
use Soundboard.DataCase
import Mock
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound, UserSoundSetting}
alias Soundboard.Sounds.Management
setup do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "mgmt_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
%{user: user}
end
test "delete_sound/2 removes local file and record", %{user: user} do
filename = "delete_#{System.unique_integer([:positive])}.mp3"
sound = insert_local_sound(user, filename)
local_path = Path.join(uploads_dir(), filename)
File.write!(local_path, "audio")
on_exit(fn -> File.rm(local_path) end)
assert File.exists?(local_path)
with_mock Soundboard.AudioPlayer, invalidate_cache: fn ^filename -> :ok end do
assert :ok = Management.delete_sound(sound, user.id)
assert_called(Soundboard.AudioPlayer.invalidate_cache(filename))
end
refute File.exists?(local_path)
assert Repo.get(Sound, sound.id) == nil
end
test "update_sound/3 renames local file and upserts user settings", %{user: user} do
filename = "old_#{System.unique_integer([:positive])}.mp3"
sound = insert_local_sound(user, filename)
old_path = Path.join(uploads_dir(), filename)
File.write!(old_path, "audio")
on_exit(fn -> File.rm(old_path) end)
params = %{
"filename" => "renamed_#{System.unique_integer([:positive])}",
"source_type" => "local",
"url" => nil,
"volume" => "80",
"is_join_sound" => "true",
"is_leave_sound" => "false"
}
new_filename = params["filename"] <> ".mp3"
with_mock Soundboard.AudioPlayer,
invalidate_cache: fn cache_key when cache_key in [filename, new_filename] -> :ok end do
assert {:ok, updated_sound} = Management.update_sound(sound, user.id, params)
assert_called(Soundboard.AudioPlayer.invalidate_cache(filename))
assert_called(Soundboard.AudioPlayer.invalidate_cache(new_filename))
new_path = Path.join(uploads_dir(), new_filename)
on_exit(fn -> File.rm(new_path) end)
assert updated_sound.filename == new_filename
assert File.exists?(new_path)
refute File.exists?(old_path)
setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: updated_sound.id)
assert setting.is_join_sound
refute setting.is_leave_sound
end
end
test "update_sound/3 keeps sound metadata collaborative while preserving uploader ownership", %{
user: user
} do
filename = "shared_#{System.unique_integer([:positive])}.mp3"
sound = insert_local_sound(user, filename)
old_path = Path.join(uploads_dir(), filename)
File.write!(old_path, "audio")
on_exit(fn -> File.rm(old_path) end)
{:ok, editor} =
%User{}
|> User.changeset(%{
username: "editor_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
params = %{
"filename" => "edited_by_other_#{System.unique_integer([:positive])}",
"source_type" => "local",
"url" => nil,
"volume" => "65",
"is_join_sound" => "true",
"is_leave_sound" => "false"
}
assert {:ok, updated_sound} = Management.update_sound(sound, editor.id, params)
new_filename = params["filename"] <> ".mp3"
new_path = Path.join(uploads_dir(), new_filename)
on_exit(fn -> File.rm(new_path) end)
assert updated_sound.filename == new_filename
assert updated_sound.user_id == user.id
assert File.exists?(new_path)
refute File.exists?(old_path)
setting = Repo.get_by!(UserSoundSetting, user_id: editor.id, sound_id: updated_sound.id)
assert setting.is_join_sound
refute setting.is_leave_sound
end
test "delete_sound/2 stays owner-only even when metadata edits are collaborative", %{user: user} do
sound = insert_local_sound(user, "locked_#{System.unique_integer([:positive])}.mp3")
{:ok, intruder} =
%User{}
|> User.changeset(%{
username: "delete_intruder_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
assert {:error, :forbidden} = Management.delete_sound(sound, intruder.id)
assert Repo.get!(Sound, sound.id)
end
defp insert_local_sound(user, filename) do
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: filename,
source_type: "local",
user_id: user.id,
volume: 1.0
})
|> Repo.insert()
sound
end
defp uploads_dir do
Soundboard.UploadsPath.dir()
end
end
================================================
FILE: test/soundboard/sounds/sound_settings_test.exs
================================================
defmodule Soundboard.Sounds.SoundSettingsTest do
@moduledoc """
The SoundSettingsTest module.
"""
use Soundboard.DataCase
alias Soundboard.{Accounts.User, Repo, Sound, UserSoundSetting}
import Ecto.Changeset
setup do
user = insert_user()
{:ok, sound} = insert_sound(user)
%{user: user, sound: sound}
end
describe "changeset validation" do
test "requires user_id and sound_id" do
changeset = UserSoundSetting.changeset(%UserSoundSetting{}, %{})
assert errors_on(changeset) == %{
user_id: ["can't be blank"],
sound_id: ["can't be blank"]
}
end
test "defaults join and leave sounds to false", %{user: user, sound: sound} do
attrs = %{
user_id: user.id,
sound_id: sound.id
}
changeset = UserSoundSetting.changeset(%UserSoundSetting{}, attrs)
assert get_field(changeset, :is_join_sound) == false
assert get_field(changeset, :is_leave_sound) == false
end
test "accepts join and leave sound settings", %{user: user, sound: sound} do
attrs = %{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true,
is_leave_sound: false
}
changeset = UserSoundSetting.changeset(%UserSoundSetting{}, attrs)
assert get_field(changeset, :is_join_sound) == true
assert get_field(changeset, :is_leave_sound) == false
end
test "building a changeset does not mutate existing join settings", %{
user: user,
sound: sound
} do
other_sound = insert_sound!(user, "other_#{System.unique_integer([:positive])}.mp3")
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: other_sound.id,
is_join_sound: true
})
|> Repo.insert!()
_changeset =
UserSoundSetting.changeset(%UserSoundSetting{}, %{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true
})
existing_setting =
Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: other_sound.id)
assert existing_setting.is_join_sound
end
end
describe "conflicting setting cleanup" do
test "clear_conflicting_settings/4 clears other join and leave flags", %{
user: user,
sound: sound
} do
other_sound = insert_sound!(user, "other_#{System.unique_integer([:positive])}.mp3")
join_setting =
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: other_sound.id,
is_join_sound: true
})
|> Repo.insert!()
leave_setting =
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: other_sound.id,
is_leave_sound: true
})
|> Repo.insert!()
assert :ok = UserSoundSetting.clear_conflicting_settings(user.id, sound.id, true, true)
refute Repo.get!(UserSoundSetting, join_setting.id).is_join_sound
refute Repo.get!(UserSoundSetting, leave_setting.id).is_leave_sound
end
end
describe "unique constraints" do
test "enforces unique join sound per user", %{user: user, sound: sound} do
# First insert succeeds
{:ok, _} =
%UserSoundSetting{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true
}
|> Repo.insert()
# Second insert should fail with constraint error
changeset =
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true
})
|> unique_constraint(:user_id,
name: "user_sound_settings_user_id_is_join_sound_index",
message: "already has a join sound"
)
{:error, changeset} = Repo.insert(changeset)
assert %{user_id: ["already has a join sound"]} = errors_on(changeset)
end
test "enforces unique leave sound per user", %{user: user, sound: sound} do
# First insert succeeds
{:ok, _} =
%UserSoundSetting{
user_id: user.id,
sound_id: sound.id,
is_leave_sound: true
}
|> Repo.insert()
# Second insert should fail with constraint error
changeset =
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: sound.id,
is_leave_sound: true
})
|> unique_constraint(:user_id,
name: "user_sound_settings_user_id_is_leave_sound_index",
message: "already has a leave sound"
)
{:error, changeset} = Repo.insert(changeset)
assert %{user_id: ["already has a leave sound"]} = errors_on(changeset)
end
end
# Helper functions
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "test_user",
discord_id: "123456",
avatar: "test.jpg"
})
|> Repo.insert()
user
end
defp insert_sound(user) do
insert_sound!(user, "test_sound#{System.unique_integer()}.mp3")
|> then(&{:ok, &1})
end
defp insert_sound!(user, filename) do
%Sound{}
|> Sound.changeset(%{
filename: filename,
source_type: "local",
user_id: user.id
})
|> Repo.insert!()
end
end
================================================
FILE: test/soundboard/sounds/tags_test.exs
================================================
defmodule Soundboard.Sounds.TagsTest do
use Soundboard.DataCase
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound, Tag}
alias Soundboard.Sounds.Tags
setup do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "tags_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "sound_#{System.unique_integer([:positive])}.mp3",
source_type: "local",
user_id: user.id,
volume: 1.0
})
|> Repo.insert()
%{user: user, sound: sound}
end
test "search/1 delegates to tag search", %{sound: sound} do
alpha = insert_tag!("alpha")
beta = insert_tag!("beta")
{:ok, _updated_sound} = Tags.update_sound_tags(sound, [alpha, beta])
assert [result] = Tags.search("alp")
assert result.name == "alpha"
end
test "all_for_sounds/1 deduplicates and sorts tags" do
alpha = %Tag{id: 2, name: "alpha"}
beta = %Tag{id: 1, name: "beta"}
sounds = [
%{tags: [beta, alpha]},
%{tags: [alpha]}
]
assert Enum.map(Tags.all_for_sounds(sounds), & &1.name) == ["alpha", "beta"]
end
test "count_sounds_with_tag/2 and tag_selected?/2 work on associations" do
alpha = %Tag{id: 1, name: "alpha"}
beta = %Tag{id: 2, name: "beta"}
sounds = [
%{tags: [alpha]},
%{tags: [alpha, beta]},
%{tags: []}
]
assert Tags.count_sounds_with_tag(sounds, alpha) == 2
assert Tags.count_sounds_with_tag(sounds, beta) == 1
assert Tags.tag_selected?(alpha, [beta, alpha])
refute Tags.tag_selected?(%Tag{id: 3, name: "gamma"}, [beta, alpha])
end
test "resolve_many/1 normalizes, deduplicates, and ignores nil-like values" do
assert {:ok, [alpha, beta]} = Tags.resolve_many([" Alpha ", "beta", "alpha", nil])
assert alpha.name == "alpha"
assert beta.name == "beta"
end
test "resolve/1 rejects blank tag names" do
assert {:error, changeset} = Tags.resolve(" ")
assert "can't be blank" in errors_on(changeset).tags
end
test "resolve/1 returns existing tag structs and non-binary values safely" do
tag = insert_tag!("existing")
assert {:ok, ^tag} = Tags.resolve(tag)
assert {:ok, nil} = Tags.resolve(:skip)
end
test "find_or_create/1 reuses normalized existing tags" do
tag = insert_tag!("mixed")
assert {:ok, resolved} = Tags.find_or_create(" MIXED ")
assert resolved.id == tag.id
end
test "list_for_sound/1 returns tags for matching sounds and [] for missing sounds", %{
sound: sound
} do
alpha = insert_tag!("listed")
{:ok, _updated_sound} = Tags.update_sound_tags(sound, [alpha])
assert [%Tag{name: "listed"}] = Tags.list_for_sound(sound.filename)
assert [] = Tags.list_for_sound("missing.mp3")
end
defp insert_tag!(name) do
%Tag{}
|> Tag.changeset(%{name: name})
|> Repo.insert!()
end
end
================================================
FILE: test/soundboard/sounds/uploads_test.exs
================================================
defmodule Soundboard.Sounds.UploadsTest do
use Soundboard.DataCase
import Soundboard.DataCase, only: [errors_on: 1]
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound, UserSoundSetting}
alias Soundboard.Sounds.Uploads
alias Soundboard.Sounds.Uploads.CreateRequest
setup do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "upload_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "test.jpg"
})
|> Repo.insert()
%{user: user}
end
describe "validate/1" do
test "validates URL uploads when enough input is present", %{user: user} do
assert {:ok, _params} =
user
|> request(%{
source_type: "url",
name: "validated_url",
url: "https://example.com/sound.mp3"
})
|> Uploads.validate()
end
test "requires a url for url uploads", %{user: user} do
assert {:error, changeset} =
user
|> request(%{
source_type: "url",
name: "validated_url"
})
|> Uploads.validate()
assert "can't be blank" in errors_on(changeset).url
end
test "rejects duplicate local filenames before copying", %{user: user} do
{:ok, _existing} =
%Sound{}
|> Sound.changeset(%{
filename: "duplicate_name.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
assert {:error, changeset} =
user
|> request(%{
source_type: "local",
name: "duplicate_name",
upload: %{filename: "dup.mp3"}
})
|> Uploads.validate()
assert "has already been taken" in errors_on(changeset).filename
end
test "requires a local file selection for local uploads", %{user: user} do
assert {:error, changeset} =
user
|> request(%{
source_type: "local",
name: "missing_file"
})
|> Uploads.validate()
assert "Please select a file" in errors_on(changeset).file
end
end
describe "create/1" do
test "creates url sound with tags and settings", %{user: user} do
name = "upload_url_#{System.unique_integer([:positive])}"
assert {:ok, sound} =
user
|> request(%{
source_type: "url",
name: name,
url: "https://example.com/sound.mp3",
tags: ["alpha", "beta"],
volume: "45",
is_join_sound: "true"
})
|> Uploads.create()
assert sound.filename == "#{name}.mp3"
assert sound.source_type == "url"
assert_in_delta sound.volume, 0.45, 0.0001
sound = Repo.preload(sound, :tags)
assert Enum.sort(Enum.map(sound.tags, & &1.name)) == ["alpha", "beta"]
setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: sound.id)
assert setting.is_join_sound
refute setting.is_leave_sound
end
test "publishes canonical soundboard events after create", %{user: user} do
Soundboard.PubSubTopics.subscribe_files()
Soundboard.PubSubTopics.subscribe_stats()
name = "upload_events_#{System.unique_integer([:positive])}"
assert {:ok, _sound} =
user
|> request(%{
source_type: "url",
name: name,
url: "https://example.com/events.mp3"
})
|> Uploads.create()
assert_receive {:files_updated}
assert_receive {:stats_updated}
end
test "copies local file and persists sound", %{user: user} do
name = "upload_local_#{System.unique_integer([:positive])}"
tmp_path = Path.join(System.tmp_dir!(), "#{System.unique_integer([:positive])}-local.wav")
File.write!(tmp_path, "audio")
on_exit(fn -> File.rm(tmp_path) end)
assert {:ok, sound} =
user
|> request(%{
source_type: "local",
name: name,
upload: %{path: tmp_path, filename: "local.wav"}
})
|> Uploads.create()
copied_path = Path.join(uploads_dir(), sound.filename)
assert File.exists?(copied_path)
on_exit(fn -> File.rm(copied_path) end)
end
test "clears previous join setting when creating a new join sound", %{user: user} do
first_name = "first_join_#{System.unique_integer([:positive])}"
second_name = "second_join_#{System.unique_integer([:positive])}"
assert {:ok, first_sound} =
user
|> request(%{
source_type: "url",
name: first_name,
url: "https://example.com/first.mp3",
is_join_sound: true
})
|> Uploads.create()
assert {:ok, second_sound} =
user
|> request(%{
source_type: "url",
name: second_name,
url: "https://example.com/second.mp3",
is_join_sound: true
})
|> Uploads.create()
first_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: first_sound.id)
second_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: second_sound.id)
refute first_setting.is_join_sound
assert second_setting.is_join_sound
end
test "returns error when local file is missing", %{user: user} do
assert {:error, changeset} =
user
|> request(%{
source_type: "local",
name: "missing_file"
})
|> Uploads.create()
assert "Please select a file" in errors_on(changeset).file
end
test "returns duplicate filename validation for local upload", %{user: user} do
{:ok, _existing} =
%Sound{}
|> Sound.changeset(%{
filename: "duplicate_name.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
tmp_path = Path.join(System.tmp_dir!(), "#{System.unique_integer([:positive])}-dup.mp3")
File.write!(tmp_path, "audio")
on_exit(fn -> File.rm(tmp_path) end)
assert {:error, changeset} =
user
|> request(%{
source_type: "local",
name: "duplicate_name",
upload: %{path: tmp_path, filename: "dup.mp3"}
})
|> Uploads.create()
assert "has already been taken" in errors_on(changeset).filename
end
end
defp request(user, attrs) do
CreateRequest.new(user, attrs)
end
defp uploads_dir do
Soundboard.UploadsPath.dir()
end
end
================================================
FILE: test/soundboard/stats_test.exs
================================================
defmodule Soundboard.StatsTest do
@moduledoc """
Test for the Stats module.
"""
use Soundboard.DataCase
alias Soundboard.{Accounts.User, PubSubTopics, Sound, Stats, Stats.Play}
describe "stats" do
setup do
user = insert_user()
sound = insert_sound(user)
%{user: user, sound: sound}
end
test "track_play creates a play record", %{user: user, sound: sound} do
assert {:ok, play} = Stats.track_play(sound.filename, user.id)
assert play.played_filename == sound.filename
assert play.sound_id == sound.id
assert play.user_id == user.id
end
test "get_top_sounds preserves the played filename snapshot after a sound is renamed", %{
user: user,
sound: sound
} do
today = Date.utc_today()
original_filename = sound.filename
Stats.track_play(original_filename, user.id)
{:ok, _renamed_sound} =
sound
|> Sound.changeset(%{filename: "renamed_#{System.unique_integer()}.mp3"})
|> Repo.update()
results = Stats.get_top_sounds(today, today)
sound_plays =
Enum.find(results, fn {filename, _count} -> filename == original_filename end)
assert sound_plays != nil
assert {^original_filename, count} = sound_plays
assert count >= 1
end
test "get_recent_plays falls back to the stored sound name when a sound is deleted", %{
user: user,
sound: sound
} do
Stats.track_play(sound.filename, user.id)
Repo.delete!(sound)
assert [{_id, filename, username, _timestamp}] = Stats.get_recent_plays(limit: 1)
assert filename == sound.filename
assert username == user.username
end
test "play changeset requires sound_id" do
changeset = Play.changeset(%Play{}, %{played_filename: "beep.mp3", user_id: 123})
assert %{sound_id: ["can't be blank"]} = errors_on(changeset)
end
test "get_top_users returns users ordered by play count", %{user: user, sound: sound} do
today = Date.utc_today()
Enum.each(1..3, fn _ -> Stats.track_play(sound.filename, user.id) end)
results = Stats.get_top_users(today, today)
user_plays = Enum.find(results, fn {username, _count} -> username == user.username end)
assert user_plays != nil
assert {_username, count} = user_plays
assert count >= 3
end
test "get_top_sounds returns sounds ordered by play count", %{user: user, sound: sound} do
today = Date.utc_today()
Enum.each(1..3, fn _ -> Stats.track_play(sound.filename, user.id) end)
results = Stats.get_top_sounds(today, today)
sound_plays = Enum.find(results, fn {filename, _count} -> filename == sound.filename end)
assert sound_plays != nil
assert {_filename, count} = sound_plays
assert count >= 3
end
test "get_recent_plays returns most recent plays", %{user: user, sound: sound} do
Stats.track_play(sound.filename, user.id)
assert [{_id, filename, username, _timestamp}] = Stats.get_recent_plays(limit: 1)
assert filename == sound.filename
assert username == user.username
end
test "reset_weekly_stats deletes old plays", %{user: user, sound: sound} do
old_date =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(-8, :day)
|> NaiveDateTime.truncate(:second)
play = %Play{
played_filename: sound.filename,
sound_id: sound.id,
user_id: user.id,
inserted_at: old_date
}
Repo.insert!(play)
Stats.track_play(sound.filename, user.id)
initial_count = length(Repo.all(Play))
Stats.reset_weekly_stats()
final_count = length(Repo.all(Play))
# Should have at least one less play after reset
assert final_count < initial_count
end
test "track_play broadcasts stats only after a successful insert", %{sound: sound} do
PubSubTopics.subscribe_stats()
assert {:error, changeset} = Stats.track_play(sound.filename, nil)
assert "can't be blank" in errors_on(changeset).user_id
refute_receive {:stats_updated}
assert {:ok, _play} = Stats.track_play(sound.filename, sound.user_id)
assert_receive {:stats_updated}
refute_receive {:stats_updated}
end
test "broadcast_stats_update sends update message" do
PubSubTopics.subscribe_stats()
Stats.broadcast_stats_update()
assert_receive {:stats_updated}
refute_receive {:stats_updated}
end
end
# Helper functions
defp insert_sound(user) do
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test_sound#{System.unique_integer()}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
sound
end
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser#{System.unique_integer()}",
discord_id: "123456789",
avatar: "test_avatar.jpg"
})
|> Repo.insert()
user
end
end
================================================
FILE: test/soundboard/tags/tag_test.exs
================================================
defmodule Soundboard.Tags.TagTest do
@moduledoc """
Tests the Tag module.
"""
use Soundboard.DataCase
alias Soundboard.Accounts.User
alias Soundboard.{Repo, Sound, Tag}
import Ecto.Changeset
describe "tag validation" do
test "requires name" do
changeset = Tag.changeset(%Tag{}, %{})
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
test "enforces unique names" do
{:ok, _tag} =
%Tag{name: "test"}
|> Tag.changeset(%{})
|> unique_constraint(:name)
|> Repo.insert()
{:error, changeset} =
%Tag{name: "test"}
|> Tag.changeset(%{})
|> unique_constraint(:name)
|> Repo.insert()
assert %{name: ["has already been taken"]} = errors_on(changeset)
end
end
describe "tag management" do
setup do
user = insert_user()
{:ok, sound} = insert_sound(user)
{:ok, tag} = %Tag{name: "test_tag"} |> Tag.changeset(%{}) |> Repo.insert()
%{sound: sound, tag: tag}
end
test "associates tags with sounds", %{sound: sound, tag: tag} do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
# Insert directly into join table with timestamps
Repo.query!(
"INSERT INTO sound_tags (sound_id, tag_id, inserted_at, updated_at) VALUES (?, ?, ?, ?)",
[sound.id, tag.id, now, now]
)
updated_sound = Repo.preload(sound, :tags)
assert [%{name: "test_tag"}] = updated_sound.tags
end
end
describe "tag search" do
setup do
{:ok, _} = Repo.insert(%Tag{name: "test"})
{:ok, _} = Repo.insert(%Tag{name: "testing"})
{:ok, _} = Repo.insert(%Tag{name: "other"})
:ok
end
test "finds tags by partial name match" do
results = Tag.search("test") |> Repo.all()
assert length(results) == 2
assert Enum.map(results, & &1.name) |> Enum.sort() == ["test", "testing"]
end
test "search is case insensitive" do
results = Tag.search("TEST") |> Repo.all()
assert length(results) == 2
assert Enum.map(results, & &1.name) |> Enum.sort() == ["test", "testing"]
end
end
# Helper functions
defp insert_user do
{:ok, user} =
%Soundboard.Accounts.User{}
|> User.changeset(%{
username: "test_user",
discord_id: "123456",
avatar: "test.jpg"
})
|> Repo.insert()
user
end
defp insert_sound(user) do
%Sound{}
|> Sound.changeset(%{
filename: "test_sound.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
end
end
================================================
FILE: test/soundboard/uploads_path_test.exs
================================================
defmodule Soundboard.UploadsPathTest do
use ExUnit.Case, async: false
alias Soundboard.UploadsPath
setup do
original_uploads_dir = Application.get_env(:soundboard, :uploads_dir)
on_exit(fn ->
if is_nil(original_uploads_dir) do
Application.delete_env(:soundboard, :uploads_dir)
else
Application.put_env(:soundboard, :uploads_dir, original_uploads_dir)
end
end)
:ok
end
test "dir/0 expands relative configured paths inside the application dir" do
Application.put_env(:soundboard, :uploads_dir, "tmp/test_uploads")
assert UploadsPath.dir() == Application.app_dir(:soundboard, "tmp/test_uploads")
end
test "dir/0 preserves absolute configured paths" do
absolute = Path.join(System.tmp_dir!(), "soundboard-uploads")
Application.put_env(:soundboard, :uploads_dir, absolute)
assert UploadsPath.dir() == absolute
end
test "file_path/1 and joined_path/1 build paths relative to uploads dir" do
base = Path.join(System.tmp_dir!(), "soundboard-uploads-paths")
Application.put_env(:soundboard, :uploads_dir, base)
assert UploadsPath.file_path("beep.mp3") == Path.join(base, "beep.mp3")
assert UploadsPath.joined_path("nested/beep.mp3") == Path.join(base, "nested/beep.mp3")
assert UploadsPath.joined_path(["nested", "beep.mp3"]) ==
Path.join([base, "nested", "beep.mp3"])
end
test "safe_joined_path/1 allows in-directory paths and rejects traversal" do
base = Path.join(System.tmp_dir!(), "soundboard-safe-uploads")
Application.put_env(:soundboard, :uploads_dir, base)
assert {:ok, ^base} = UploadsPath.safe_joined_path(["."])
assert {:ok, safe_path} = UploadsPath.safe_joined_path(["nested", "clip.mp3"])
assert safe_path == Path.join([base, "nested", "clip.mp3"]) |> Path.expand()
assert :error = UploadsPath.safe_joined_path(["..", "escape.mp3"])
assert :error = UploadsPath.safe_joined_path("../escape.mp3")
end
end
================================================
FILE: test/soundboard/volume_test.exs
================================================
defmodule Soundboard.VolumeTest do
use ExUnit.Case, async: true
alias Soundboard.Volume
describe "normalize_percent/2" do
test "handles integers" do
assert Volume.normalize_percent(75, 100) == 75
assert Volume.normalize_percent(-5, 50) == 0
assert Volume.normalize_percent(150, 50) == 150
end
test "handles strings and fallbacks" do
assert Volume.normalize_percent("42", 100) == 42
assert Volume.normalize_percent(" 84.5 ", 10) == 85
assert Volume.normalize_percent("garbage", 30) == 30
end
end
describe "percent_to_decimal/2" do
test "converts to decimal with fallback" do
assert Volume.percent_to_decimal("50", 100) == 0.5
assert Volume.percent_to_decimal(nil, 80) == 0.8
assert Volume.percent_to_decimal("110", 100) == 1.1
assert Volume.percent_to_decimal("150", 100) == 1.5
assert Volume.percent_to_decimal("200", 25) == 1.5
end
end
describe "decimal_to_percent/1" do
test "handles nil and bounds" do
assert Volume.decimal_to_percent(nil) == 100
assert Volume.decimal_to_percent(0.0625) == 6
assert Volume.decimal_to_percent(0.64) == 64
assert Volume.decimal_to_percent(1.1) == 110
assert Volume.decimal_to_percent(1.4) == 140
assert Volume.decimal_to_percent(1.6) == 150
assert Volume.decimal_to_percent(-0.2) == 0
end
end
end
================================================
FILE: test/soundboard_test.exs
================================================
defmodule SoundboardTest do
use ExUnit.Case, async: true
doctest Soundboard
describe "module documentation" do
test "module exists" do
assert {:module, Soundboard} == Code.ensure_loaded(Soundboard)
assert function_exported?(Soundboard, :__info__, 1)
end
test "has module documentation" do
moduledoc = Code.fetch_docs(Soundboard)
assert match?({:docs_v1, _, :elixir, _, %{"en" => _}, _, _}, moduledoc)
{:docs_v1, _, :elixir, _, %{"en" => doc}, _, _} = moduledoc
assert doc =~ "Soundboard keeps the contexts"
assert doc =~ "business logic"
end
test "returns application name" do
assert Soundboard.app_name() == :soundboard
end
end
end
================================================
FILE: test/soundboard_web/audio_player_test.exs
================================================
defmodule Soundboard.AudioPlayerTest do
use ExUnit.Case, async: false
import Mock
alias Soundboard.Accounts.User
alias Soundboard.AudioPlayer
alias Soundboard.AudioPlayer.State
alias Soundboard.Discord.Handler.{AutoJoinPolicy, VoicePresence}
alias Soundboard.Discord.Voice
setup do
original_state = :sys.get_state(AudioPlayer)
on_exit(fn ->
:sys.replace_state(AudioPlayer, fn _ -> original_state end)
end)
:ok
end
test "continues voice maintenance when playback status is unavailable" do
test_pid = self()
:sys.replace_state(AudioPlayer, fn _ ->
%State{
voice_channel: {"guild-1", "channel-1"},
current_playback: nil,
pending_request: nil,
interrupting: false,
interrupt_watchdog_ref: nil,
interrupt_watchdog_attempt: 0
}
end)
with_mock Voice,
channel_id: fn "guild-1" -> "channel-1" end,
ready?: fn "guild-1" -> false end,
playing?: fn "guild-1" -> raise "playback status unavailable" end,
leave_channel: fn "guild-1" -> :ok end,
join_channel: fn "guild-1", "channel-1" ->
send(test_pid, :join_attempted)
:ok
end do
send(AudioPlayer, :check_voice_connection)
# leave→rejoin includes a 1s sleep between leave and join
assert_receive :join_attempted, 2_000
end
end
describe "idle timeout" do
test "schedules idle timeout when voice channel is set (play mode)" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
AudioPlayer.set_voice_channel("guild-1", "ch-1")
# Sync: the call queues after the cast, ensuring the cast is processed first
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
assert state.idle_timeout_ref != nil
end
test "does not schedule idle timeout in presence mode" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mock AutoJoinPolicy, mode: fn -> :presence end do
AudioPlayer.set_voice_channel("guild-1", "ch-1")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
assert state.idle_timeout_ref == nil
end
end
test "does not schedule idle timeout in false mode on join" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mock AutoJoinPolicy, mode: fn -> false end do
AudioPlayer.set_voice_channel("guild-1", "ch-1")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
assert state.idle_timeout_ref == nil
end
end
test "cancels idle timeout when voice channel is cleared" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: {make_ref(), make_ref()}}
end)
AudioPlayer.set_voice_channel(nil, nil)
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == nil
assert state.idle_timeout_ref == nil
end
test "resets idle timeout when a sound is played (play mode)" do
token = make_ref()
:sys.replace_state(AudioPlayer, fn state ->
%{
state
| voice_channel: {"guild-1", "ch-1"},
idle_timeout_ref: {make_ref(), token},
current_playback: nil,
pending_request: nil,
interrupting: false,
interrupt_watchdog_attempt: 0
}
end)
with_mocks([
{Soundboard.AudioPlayer.SoundLibrary, [],
[get_sound_path: fn "test.mp3" -> {:ok, {"/path/test.mp3", 1.0}} end]},
{Soundboard.AudioPlayer.PlaybackEngine, [], [play: fn _, _, _, _, _, _ -> :ok end]}
]) do
AudioPlayer.play_sound("test.mp3", "actor")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
# A new token means the timer was reset
{_ref, new_token} = state.idle_timeout_ref
assert new_token != token
end
end
test "does not reset idle timeout when a sound is played in presence mode" do
token = make_ref()
:sys.replace_state(AudioPlayer, fn state ->
%{
state
| voice_channel: {"guild-1", "ch-1"},
idle_timeout_ref: {make_ref(), token},
current_playback: nil,
pending_request: nil,
interrupting: false,
interrupt_watchdog_attempt: 0
}
end)
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :presence end]},
{Soundboard.AudioPlayer.SoundLibrary, [],
[get_sound_path: fn "test.mp3" -> {:ok, {"/path/test.mp3", 1.0}} end]},
{Soundboard.AudioPlayer.PlaybackEngine, [], [play: fn _, _, _, _, _, _ -> :ok end]}
]) do
AudioPlayer.play_sound("test.mp3", "actor")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
{_ref, unchanged_token} = state.idle_timeout_ref
assert unchanged_token == token
end
end
test "idle timeout fires and leaves the voice channel" do
test_pid = self()
token = make_ref()
:sys.replace_state(AudioPlayer, fn state ->
%{
state
| voice_channel: {"guild-1", "ch-1"},
idle_timeout_ref: {make_ref(), token},
current_playback: nil
}
end)
with_mock Voice,
leave_channel: fn "guild-1" ->
send(test_pid, :leave_called)
:ok
end do
send(AudioPlayer, {:idle_timeout, token})
assert_receive :leave_called, 1_000
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == nil
assert state.idle_timeout_ref == nil
end
end
test "stale idle timeout tokens are ignored" do
test_pid = self()
active_token = make_ref()
stale_token = make_ref()
:sys.replace_state(AudioPlayer, fn state ->
%{
state
| voice_channel: {"guild-1", "ch-1"},
idle_timeout_ref: {make_ref(), active_token}
}
end)
with_mock Voice,
leave_channel: fn _ ->
send(test_pid, :leave_called)
:ok
end do
send(AudioPlayer, {:idle_timeout, stale_token})
# Sync, then verify nothing happened
AudioPlayer.current_voice_channel()
refute_receive :leave_called, 100
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
end
end
end
describe "last_user_left" do
test "leaves immediately in play mode" do
test_pid = self()
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: nil}
end)
with_mock Voice,
leave_channel: fn "guild-1" ->
send(test_pid, :leave_called)
:ok
end do
AudioPlayer.last_user_left("guild-1")
assert_receive :leave_called, 1_000
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == nil
assert state.idle_timeout_ref == nil
end
end
test "leaves immediately in presence mode" do
test_pid = self()
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: nil}
end)
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> :presence end]},
{Voice, [],
[
leave_channel: fn "guild-1" ->
send(test_pid, :leave_called)
:ok
end
]}
]) do
AudioPlayer.last_user_left("guild-1")
assert_receive :leave_called, 1_000
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == nil
end
end
test "starts idle timer in false mode (with timeout configured)" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: nil}
end)
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{Soundboard.Discord.Handler.IdleTimeoutPolicy, [], [timeout_ms: fn -> 60_000 end]},
{Voice, [], [leave_channel: fn _ -> :ok end]}
]) do
AudioPlayer.last_user_left("guild-1")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
assert state.idle_timeout_ref != nil
refute called(Voice.leave_channel(:_))
end
end
test "stays in false mode when timeout is disabled (0)" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: nil}
end)
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{Soundboard.Discord.Handler.IdleTimeoutPolicy, [], [timeout_ms: fn -> nil end]},
{Voice, [], [leave_channel: fn _ -> :ok end]}
]) do
AudioPlayer.last_user_left("guild-1")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-1"}
assert state.idle_timeout_ref == nil
refute called(Voice.leave_channel(:_))
end
end
test "ignores last_user_left when bot is not in a channel" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil}
end)
with_mock Voice, leave_channel: fn _ -> :ok end do
AudioPlayer.last_user_left("guild-1")
AudioPlayer.current_voice_channel()
refute called(Voice.leave_channel(:_))
end
end
end
describe "user_joined_channel" do
test "cancels idle timer" do
token = make_ref()
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: {"guild-1", "ch-1"}, idle_timeout_ref: {make_ref(), token}}
end)
AudioPlayer.user_joined_channel("guild-1")
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.idle_timeout_ref == nil
end
end
describe "auto-join on play" do
test "auto-joins user's voice channel when bot has no channel and actor has discord_id" do
test_pid = self()
user = %User{discord_id: "discord-99", username: "tester", id: 1}
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mocks([
{VoicePresence, [],
[find_user_voice_channel: fn "discord-99" -> {:ok, {"guild-1", "ch-5"}} end]},
{Voice, [],
[
join_channel: fn "guild-1", "ch-5" ->
send(test_pid, :join_called)
:ok
end
]},
{Soundboard.AudioPlayer.SoundLibrary, [],
[get_sound_path: fn _ -> {:error, "not found"} end]}
]) do
AudioPlayer.play_sound("any.mp3", user)
assert_receive :join_called, 1_000
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == {"guild-1", "ch-5"}
assert state.idle_timeout_ref != nil
end
end
test "shows error and skips auto-join when user is not in any voice channel" do
user = %User{discord_id: "discord-99", username: "tester", id: 1}
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mocks([
{VoicePresence, [], [find_user_voice_channel: fn "discord-99" -> :not_found end]},
{Voice, [], [join_channel: fn _, _ -> :ok end]}
]) do
AudioPlayer.play_sound("any.mp3", user)
AudioPlayer.current_voice_channel()
state = :sys.get_state(AudioPlayer)
assert state.voice_channel == nil
refute called(Voice.join_channel(:_, :_))
end
end
test "skips auto-join for actors without discord_id" do
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mocks([
{VoicePresence, [], [find_user_voice_channel: fn _ -> {:ok, {"guild-1", "ch-1"}} end]},
{Voice, [], [join_channel: fn _, _ -> :ok end]}
]) do
AudioPlayer.play_sound("any.mp3", "System")
AudioPlayer.current_voice_channel()
refute called(VoicePresence.find_user_voice_channel(:_))
refute called(Voice.join_channel(:_, :_))
end
end
test "skips auto-join in false mode" do
user = %User{discord_id: "discord-99", username: "tester", id: 1}
:sys.replace_state(AudioPlayer, fn state ->
%{state | voice_channel: nil, idle_timeout_ref: nil}
end)
with_mocks([
{AutoJoinPolicy, [], [mode: fn -> false end]},
{VoicePresence, [], [find_user_voice_channel: fn _ -> {:ok, {"guild-1", "ch-1"}} end]},
{Voice, [], [join_channel: fn _, _ -> :ok end]}
]) do
AudioPlayer.play_sound("any.mp3", user)
AudioPlayer.current_voice_channel()
refute called(VoicePresence.find_user_voice_channel(:_))
refute called(Voice.join_channel(:_, :_))
end
end
end
end
================================================
FILE: test/soundboard_web/components/layouts/navbar_test.exs
================================================
defmodule SoundboardWeb.Components.Layouts.NavbarTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias SoundboardWeb.Components.Layouts.Navbar
test "renders public navigation links" do
html =
render_component(Navbar,
id: "navbar",
current_path: "/",
current_user: nil,
presences: %{}
)
assert html =~ "SoundBored"
assert html =~ "Sounds"
assert html =~ "Favorites"
assert html =~ "Stats"
refute html =~ "Settings"
end
test "renders settings link and deduplicated presences for authenticated users" do
html =
render_component(Navbar,
id: "navbar",
current_path: "/settings",
current_user: %{id: 1, username: "owner"},
presences: %{
"1" => %{metas: [%{user: %{username: "alice", avatar: "alice.png"}}]},
"2" => %{metas: [%{user: %{username: "alice", avatar: "alice.png"}}]},
"3" => %{metas: [%{user: %{username: "bob", avatar: "bob.png"}}]}
}
)
assert html =~ "Settings"
assert html =~ "user-alice"
assert html =~ "user-bob"
# Duplicated presence entries for the same user should only render once per menu section.
assert length(Regex.scan(~r/user-alice/, html)) == 2
end
test "toggle-mobile-menu flips show_mobile_menu assign" do
{:ok, socket} = Navbar.mount(%Phoenix.LiveView.Socket{})
{:noreply, socket} = Navbar.handle_event("toggle-mobile-menu", %{}, socket)
assert socket.assigns.show_mobile_menu
{:noreply, socket} = Navbar.handle_event("toggle-mobile-menu", %{}, socket)
refute socket.assigns.show_mobile_menu
end
end
================================================
FILE: test/soundboard_web/components/soundboard/edit_modal_test.exs
================================================
defmodule SoundboardWeb.Components.Soundboard.EditModalTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias SoundboardWeb.Components.Soundboard.EditModal
test "renders edit form with local file metadata" do
html = render_component(&EditModal.edit_modal/1, edit_assigns())
assert html =~ "Edit Sound"
assert html =~ "Local File"
assert html =~ "Save Changes"
assert html =~ "Delete Sound"
end
test "renders source URL and edit validation errors" do
html =
render_component(
&EditModal.edit_modal/1,
edit_assigns(%{
current_sound: %{
edit_sound()
| source_type: "url",
url: "https://example.com/sound.mp3"
},
edit_name_error: "Name already taken"
})
)
assert html =~ "URL: https://example.com/sound.mp3"
assert html =~ "Name already taken"
end
test "hides delete for non-owners" do
html =
render_component(
&EditModal.edit_modal/1,
edit_assigns(%{
current_user: %{id: 2}
})
)
refute html =~ "Delete Sound"
end
defp edit_assigns(overrides \\ %{}) do
base = %{
current_sound: edit_sound(),
current_user: %{id: 1},
tag_input: "",
tag_suggestions: [],
edit_name_error: nil,
flash: %{}
}
Map.merge(base, overrides)
end
defp edit_sound do
%{
id: 10,
filename: "laser.mp3",
source_type: "local",
url: nil,
volume: 1.0,
tags: [%{name: "funny"}],
user_id: 1,
user_sound_settings: [%{user_id: 1, is_join_sound: true, is_leave_sound: false}]
}
end
end
================================================
FILE: test/soundboard_web/components/soundboard/upload_modal_test.exs
================================================
defmodule SoundboardWeb.Components.Soundboard.UploadModalTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias SoundboardWeb.Components.Soundboard.UploadModal
test "renders url workflow and prompts for missing URL" do
html = render_component(&UploadModal.upload_modal/1, upload_assigns())
assert html =~ "Add Sound"
assert html =~ "Source Type"
assert html =~ "Enter a URL first to name it."
end
test "renders filled URL workflow without missing-url helper text" do
html =
render_component(
&UploadModal.upload_modal/1,
upload_assigns(%{
source_type: "url",
url: "https://example.com/clip.mp3",
upload_name: "clip"
})
)
assert html =~ "upload-url-input"
refute html =~ "Enter a URL first to name it."
end
test "disables submit when upload validation error is present" do
html =
render_component(
&UploadModal.upload_modal/1,
upload_assigns(%{
source_type: "url",
url: "https://example.com/clip.mp3",
upload_name: "clip",
upload_error: "URL is invalid"
})
)
assert html =~ "disabled"
assert html =~ "URL is invalid"
end
defp upload_assigns(overrides \\ %{}) do
base = %{
source_type: "url",
uploads: %{audio: %{entries: []}},
url: "",
upload_name: "",
upload_error: nil,
upload_tags: [],
upload_tag_input: "",
upload_tag_suggestions: [],
upload_volume: 100,
is_join_sound: false,
is_leave_sound: false
}
Map.merge(base, overrides)
end
end
================================================
FILE: test/soundboard_web/controllers/api/sound_controller_test.exs
================================================
defmodule SoundboardWeb.API.SoundControllerTest do
@moduledoc """
Test for the SoundController.
"""
use SoundboardWeb.ConnCase
import Mock
alias Soundboard.Accounts.{ApiTokens, User}
alias Soundboard.{Repo, Sound, Tag, UserSoundSetting}
setup %{conn: conn} do
user = insert_user()
sound = insert_sound(user)
tag = insert_tag()
{:ok, raw_token, _token} = ApiTokens.generate_token(user, %{label: "API Test"})
insert_sound_tag(sound, tag)
conn =
conn
|> put_req_header("authorization", "Bearer " <> raw_token)
%{conn: conn, sound: sound, user: user}
end
describe "index" do
test "lists all sounds with their tags", %{conn: conn} do
conn = get(conn, ~p"/api/sounds")
assert %{"data" => sounds} = json_response(conn, 200)
Enum.each(sounds, fn sound_data ->
assert is_integer(sound_data["id"])
assert is_binary(sound_data["filename"])
assert is_list(sound_data["tags"])
assert sound_data["inserted_at"]
assert sound_data["updated_at"]
end)
end
test "returns sounds in expected format", %{conn: conn, sound: sound} do
conn = get(conn, ~p"/api/sounds")
assert %{"data" => sounds} = json_response(conn, 200)
test_sound = Enum.find(sounds, &(&1["id"] == sound.id))
assert test_sound
assert test_sound["filename"] == sound.filename
assert is_list(test_sound["tags"])
end
test "includes join and leave flags for the authenticated user", %{
conn: conn,
sound: sound,
user: user
} do
%UserSoundSetting{}
|> UserSoundSetting.changeset(%{
user_id: user.id,
sound_id: sound.id,
is_join_sound: true,
is_leave_sound: false
})
|> Repo.insert!()
conn = get(conn, ~p"/api/sounds")
assert %{"data" => sounds} = json_response(conn, 200)
test_sound = Enum.find(sounds, &(&1["id"] == sound.id))
assert test_sound["is_join_sound"] == true
assert test_sound["is_leave_sound"] == false
end
end
describe "create" do
test "creates a URL sound", %{conn: conn, user: user} do
name = "api_url_#{System.unique_integer([:positive])}"
conn =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"name" => name,
"url" => "https://example.com/wow.mp3",
"tags" => ["meme", "reaction"],
"volume" => "35",
"is_join_sound" => "true"
})
assert %{"data" => data} = json_response(conn, 201)
assert data["filename"] == "#{name}.mp3"
assert data["source_type"] == "url"
assert data["url"] == "https://example.com/wow.mp3"
assert data["is_join_sound"] == true
sound = Repo.get_by!(Sound, filename: "#{name}.mp3") |> Repo.preload(:tags)
assert Enum.sort(Enum.map(sound.tags, & &1.name)) == ["meme", "reaction"]
assert_in_delta sound.volume, 0.35, 0.0001
setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: sound.id)
assert setting.is_join_sound
refute setting.is_leave_sound
end
test "infers URL source type when source_type is omitted", %{conn: conn} do
name = "api_url_inferred_#{System.unique_integer([:positive])}"
conn =
post(conn, ~p"/api/sounds", %{
"name" => name,
"url" => "https://example.com/inferred.mp3"
})
assert %{"data" => data} = json_response(conn, 201)
assert data["source_type"] == "url"
assert data["filename"] == "#{name}.mp3"
end
test "creates a local multipart sound and saves the file", %{conn: conn} do
name = "api_local_#{System.unique_integer([:positive])}"
tmp_path = temp_upload_path("sample.mp3")
File.write!(tmp_path, "audio")
on_exit(fn -> File.rm(tmp_path) end)
upload = %Plug.Upload{path: tmp_path, filename: "sample.mp3", content_type: "audio/mpeg"}
conn =
post(conn, ~p"/api/sounds", %{
"source_type" => "local",
"name" => name,
"file" => upload,
"tags" => "api,local",
"volume" => "120",
"is_leave_sound" => "true"
})
assert %{"data" => data} = json_response(conn, 201)
assert data["filename"] == "#{name}.mp3"
assert data["source_type"] == "local"
assert data["is_leave_sound"] == true
sound = Repo.get_by!(Sound, filename: "#{name}.mp3")
assert_in_delta sound.volume, 1.2, 0.0001
copied_file = Path.join(uploads_dir(), sound.filename)
assert File.exists?(copied_file)
on_exit(fn -> File.rm(copied_file) end)
end
test "infers local source type when multipart file is present", %{conn: conn} do
name = "api_local_inferred_#{System.unique_integer([:positive])}"
tmp_path = temp_upload_path("inferred.mp3")
File.write!(tmp_path, "audio")
on_exit(fn -> File.rm(tmp_path) end)
upload = %Plug.Upload{path: tmp_path, filename: "inferred.mp3", content_type: "audio/mpeg"}
conn =
post(conn, ~p"/api/sounds", %{
"name" => name,
"file" => upload
})
assert %{"data" => data} = json_response(conn, 201)
assert data["source_type"] == "local"
assert data["filename"] == "#{name}.mp3"
on_exit(fn -> File.rm(Path.join(uploads_dir(), "#{name}.mp3")) end)
end
test "clears previous join sound when creating a new join sound", %{conn: conn, user: user} do
first_name = "join_one_#{System.unique_integer([:positive])}"
second_name = "join_two_#{System.unique_integer([:positive])}"
_ =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"name" => first_name,
"url" => "https://example.com/first.mp3",
"is_join_sound" => "true"
})
_ =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"name" => second_name,
"url" => "https://example.com/second.mp3",
"is_join_sound" => "true"
})
first_sound = Repo.get_by!(Sound, filename: "#{first_name}.mp3")
second_sound = Repo.get_by!(Sound, filename: "#{second_name}.mp3")
first_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: first_sound.id)
second_setting = Repo.get_by!(UserSoundSetting, user_id: user.id, sound_id: second_sound.id)
refute first_setting.is_join_sound
assert second_setting.is_join_sound
end
test "returns validation errors for missing fields", %{conn: conn} do
conn_missing_name =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"url" => "https://example.com/missing-name.mp3"
})
assert %{"errors" => errors} = json_response(conn_missing_name, 422)
assert "can't be blank" in errors["filename"]
conn_missing_url =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"name" => "missing_url"
})
assert %{"errors" => errors} = json_response(conn_missing_url, 422)
assert "can't be blank" in errors["url"]
conn_missing_file =
post(conn, ~p"/api/sounds", %{
"source_type" => "local",
"name" => "missing_file"
})
assert %{"errors" => errors} = json_response(conn_missing_file, 422)
assert "Please select a file" in errors["file"]
end
test "returns validation error for duplicate filename", %{conn: conn, user: user} do
duplicate_name = "dup_#{System.unique_integer([:positive])}"
{:ok, _} =
%Sound{}
|> Sound.changeset(%{
filename: "#{duplicate_name}.mp3",
source_type: "url",
url: "https://example.com/original.mp3",
user_id: user.id
})
|> Repo.insert()
conn =
post(conn, ~p"/api/sounds", %{
"source_type" => "url",
"name" => duplicate_name,
"url" => "https://example.com/new.mp3"
})
assert %{"errors" => errors} = json_response(conn, 422)
assert "has already been taken" in errors["filename"]
end
test "returns unauthorized without valid token" do
conn =
build_conn()
|> put_req_header("authorization", "Bearer badtoken")
|> post(~p"/api/sounds", %{
"source_type" => "url",
"name" => "invalid",
"url" => "https://example.com/test.mp3"
})
assert json_response(conn, 401)
end
end
describe "play" do
test "plays a sound as the authenticated token user", %{conn: conn, sound: sound, user: user} do
with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _actor -> :ok end do
conn = post(conn, ~p"/api/sounds/#{sound.id}/play")
assert %{
"data" => %{
"status" => "accepted",
"message" => "Playback request accepted for " <> _,
"requested_by" => requested_by,
"sound" => %{"id" => sound_id, "filename" => filename}
}
} = json_response(conn, 202)
assert requested_by == user.username
assert sound_id == sound.id
assert filename == sound.filename
assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))
end
end
test "ignores x-username and attributes playback to the token user", %{
conn: conn,
sound: sound,
user: user
} do
with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _actor -> :ok end do
conn =
conn
|> put_req_header("x-username", "TestUser")
|> post(~p"/api/sounds/#{sound.id}/play")
assert %{
"data" => %{
"requested_by" => requested_by,
"sound" => %{"id" => sound_id, "filename" => filename}
}
} = json_response(conn, 202)
assert requested_by == user.username
assert sound_id == sound.id
assert filename == sound.filename
assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))
end
end
test "returns error when sound not found", %{conn: conn} do
with_mock Soundboard.AudioPlayer, play_sound: fn _filename, _username -> :ok end do
conn = post(conn, ~p"/api/sounds/999999/play")
assert %{"error" => "Sound not found"} = json_response(conn, 404)
end
end
test "returns unauthorized without valid API token" do
conn = build_conn()
conn = post(conn, ~p"/api/sounds/1/play")
assert json_response(conn, 401)
end
end
defp insert_sound(user) do
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test_sound#{System.unique_integer([:positive])}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
sound
end
defp insert_tag do
{:ok, tag} =
%Tag{}
|> Tag.changeset(%{name: "test_tag#{System.unique_integer([:positive])}"})
|> Repo.insert()
tag
end
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser#{System.unique_integer([:positive])}",
discord_id: "#{System.unique_integer([:positive])}",
avatar: "test_avatar.jpg"
})
|> Repo.insert()
user
end
defp insert_sound_tag(sound, tag) do
{:ok, _} =
%Soundboard.SoundTag{}
|> Soundboard.SoundTag.changeset(%{
sound_id: sound.id,
tag_id: tag.id
})
|> Repo.insert()
end
defp uploads_dir do
Soundboard.UploadsPath.dir()
end
defp temp_upload_path(filename) do
Path.join(System.tmp_dir!(), "#{System.unique_integer([:positive])}-#{filename}")
end
end
================================================
FILE: test/soundboard_web/controllers/auth_controller_test.exs
================================================
defmodule SoundboardWeb.AuthControllerTest do
use SoundboardWeb.ConnCase
alias Soundboard.{Accounts.User, Repo}
import ExUnit.CaptureLog
import Mock
alias EDA.API.Member
setup %{conn: conn} do
# Clean up users before each test
Repo.delete_all(User)
# Initialize session and CSRF token for all tests
conn =
conn
|> init_test_session(%{})
|> fetch_session()
|> fetch_flash()
# Mock Discord OAuth config for tests
Application.put_env(:ueberauth, Ueberauth.Strategy.Discord.OAuth,
client_id: "test_client_id",
client_secret: "test_client_secret"
)
on_exit(fn ->
Application.delete_env(:ueberauth, Ueberauth.Strategy.Discord.OAuth)
end)
{:ok, conn: conn}
end
describe "auth flow" do
test "request/2 initiates Discord auth and sets session", %{conn: conn} do
conn = get(conn, ~p"/auth/discord")
# Redirect status
assert conn.status == 302
assert String.starts_with?(
redirected_to(conn),
"https://discord.com/api/oauth2/authorize"
)
end
test "request/2 rejects unsupported providers with a controlled 404", %{conn: conn} do
conn = get(conn, "/auth/not-real")
assert response(conn, 404) == "Unsupported auth provider"
end
test "callback/2 creates new user on successful auth", %{conn: conn} do
auth_data = %{
uid: "12345",
info: %{
nickname: "TestUser",
image: "test_avatar.jpg"
}
}
conn =
conn
|> assign(:ueberauth_auth, auth_data)
|> get(~p"/auth/discord/callback")
assert redirected_to(conn) == "/"
assert get_session(conn, :user_id)
user = Repo.get_by(User, discord_id: "12345")
assert user
assert user.username == "TestUser"
assert user.avatar == "test_avatar.jpg"
end
test "callback/2 uses existing user if found", %{conn: conn} do
# Get initial user count
initial_count = Repo.aggregate(User, :count)
# Create existing user
{:ok, existing_user} =
%User{}
|> User.changeset(%{
discord_id: "12345",
username: "ExistingUser",
avatar: "old_avatar.jpg"
})
|> Repo.insert()
auth_data = %{
uid: "12345",
info: %{
nickname: "TestUser",
image: "test_avatar.jpg"
}
}
conn =
conn
|> assign(:ueberauth_auth, auth_data)
|> get(~p"/auth/discord/callback")
final_count = Repo.aggregate(User, :count)
assert redirected_to(conn) == "/"
assert get_session(conn, :user_id) == existing_user.id
# Only increased by the one we created
assert final_count == initial_count + 1
end
test "callback/2 handles auth failures", %{conn: conn} do
capture_log(fn ->
conn =
conn
|> assign(:ueberauth_failure, %{
errors: [
%Ueberauth.Failure.Error{
message_key: "invalid_credentials",
message: "Invalid credentials"
}
]
})
|> get(~p"/auth/discord/callback")
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed to authenticate"
end)
end
test "logout/2 clears session and redirects", %{conn: conn} do
conn =
conn
|> put_session(:user_id, "test_id")
|> delete(~p"/auth/logout")
assert redirected_to(conn) == "/"
refute get_session(conn, :user_id)
end
test "debug_session/2 returns limited session info", %{conn: conn} do
user = insert_user()
conn =
conn
|> put_session(:session_id, 123)
|> put_session(:user_id, user.id)
|> get(~p"/debug/session")
assert json = json_response(conn, 200)
assert json == %{"session" => %{"session_id" => 123, "user_id" => user.id}}
end
end
describe "role-gated access" do
test "callback/2 sets roles_verified_at session key on successful auth", %{conn: conn} do
# Feature is disabled in test env (no guild_id/role_ids configured),
# so RoleChecker.authorized?/1 returns true without any mocking.
auth_data = %{
uid: "99999",
info: %{
nickname: "RoleUser",
image: "role_avatar.jpg"
}
}
conn =
conn
|> assign(:ueberauth_auth, auth_data)
|> get(~p"/auth/discord/callback")
assert redirected_to(conn) == "/"
assert get_session(conn, :user_id)
assert is_integer(get_session(conn, :roles_verified_at))
end
test "callback/2 rejects unauthorized user without creating user record", %{conn: conn} do
previous_guild = Application.get_env(:soundboard, :required_guild_id)
previous_roles = Application.get_env(:soundboard, :required_role_ids)
Application.put_env(:soundboard, :required_guild_id, "test_guild")
Application.put_env(:soundboard, :required_role_ids, ["required_role"])
on_exit(fn ->
if is_nil(previous_guild),
do: Application.delete_env(:soundboard, :required_guild_id),
else: Application.put_env(:soundboard, :required_guild_id, previous_guild)
if is_nil(previous_roles),
do: Application.delete_env(:soundboard, :required_role_ids),
else: Application.put_env(:soundboard, :required_role_ids, previous_roles)
end)
user_count_before = Repo.aggregate(User, :count)
auth_data = %{
uid: "unauthorized_user",
info: %{
nickname: "UnauthorizedUser",
image: "avatar.jpg"
}
}
with_mock Member,
get: fn "test_guild", "unauthorized_user" -> {:ok, %{"roles" => ["other_role"]}} end do
conn =
conn
|> assign(:ueberauth_auth, auth_data)
|> get(~p"/auth/discord/callback")
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Error signing in"
refute get_session(conn, :user_id)
assert Repo.aggregate(User, :count) == user_count_before
end
end
end
# Helper function
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser#{System.unique_integer([:positive])}",
discord_id: "#{System.unique_integer([:positive])}",
avatar: "test_avatar.jpg"
})
|> Repo.insert()
user
end
end
================================================
FILE: test/soundboard_web/controllers/upload_controller_test.exs
================================================
defmodule SoundboardWeb.UploadControllerTest do
use SoundboardWeb.ConnCase
alias Soundboard.Accounts.User
alias Soundboard.Repo
setup %{conn: conn} do
filename = "upload_controller_#{System.unique_integer([:positive])}.mp3"
uploads_dir = Soundboard.UploadsPath.dir()
file_path = Path.join(uploads_dir, filename)
File.mkdir_p!(uploads_dir)
File.write!(file_path, "audio")
on_exit(fn -> File.rm(file_path) end)
%{conn: conn, filename: filename}
end
test "GET /uploads/*path redirects unauthenticated users", %{conn: conn, filename: filename} do
conn = get(conn, ~p"/uploads/#{filename}")
assert redirected_to(conn) == "/auth/discord"
end
test "GET /uploads/*path serves files for authenticated users", %{
conn: conn,
filename: filename
} do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "upload_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
conn =
conn
|> init_test_session(%{user_id: user.id})
|> get(~p"/uploads/#{filename}")
assert response(conn, 200) == "audio"
end
test "GET /uploads/*path rejects traversal attempts", %{conn: conn} do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "upload_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert()
conn =
conn
|> init_test_session(%{user_id: user.id})
|> get("/uploads/../../mix.exs")
assert response(conn, 404) == "File not found"
end
end
================================================
FILE: test/soundboard_web/discord_handler_test.exs
================================================
defmodule Soundboard.Discord.HandlerTest do
@moduledoc """
Tests the DiscordHandler module.
"""
use Soundboard.DataCase
import ExUnit.CaptureLog
import Mock
alias Soundboard.{Accounts.User, Repo, Sound, UserSoundSetting}
alias Soundboard.Discord.Handler
alias Soundboard.Discord.Voice
setup do
:persistent_term.put(:soundboard_bot_ready, true)
on_exit(fn ->
:persistent_term.erase(:soundboard_bot_ready)
end)
:ok
end
describe "handle_event/1" do
test "handles voice state updates" do
mock_guild = %{
id: "456",
voice_states: [
%{
user_id: "789",
channel_id: "123",
guild_id: "456",
session_id: "abc"
}
]
}
capture_log(fn ->
with_mocks([
{Soundboard.Discord.Handler.AutoJoinPolicy, [], [mode: fn -> :presence end]},
{Soundboard.Discord.Voice, [],
[
join_channel: fn _, _ -> :ok end,
ready?: fn _ -> false end
]},
{Soundboard.Discord.GuildCache, [], [get: fn _guild_id -> {:ok, mock_guild} end]},
{Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: "999"}} end]}
]) do
payload = %{
channel_id: "123",
guild_id: "456",
user_id: "789",
session_id: "abc"
}
Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})
assert_called(Voice.join_channel("456", "123"))
end
end)
end
test "does not auto-join when guild cache is unavailable" do
{:ok, recorder} = Agent.start_link(fn -> [] end)
capture_log(fn ->
with_mocks([
{Soundboard.Discord.Voice, [],
[
join_channel: fn guild_id, channel_id ->
Agent.update(recorder, &(&1 ++ [{guild_id, channel_id}]))
:ok
end,
ready?: fn _ -> false end
]},
{Soundboard.Discord.GuildCache, [],
[all: fn -> [] end, get: fn _guild_id -> :error end]},
{Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: "999"}} end]}
]) do
payload = %{
channel_id: "123",
guild_id: "456",
user_id: "789",
session_id: "abc"
}
Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})
assert Agent.get(recorder, & &1) == []
end
end)
end
test "plays join sounds immediately without artificial delay" do
user = insert_user!(%{discord_id: "555", username: "joiner"})
sound = insert_sound!(user, %{filename: "join.mp3"})
insert_user_sound_setting!(user, sound, %{is_join_sound: true})
bot_id = "999"
guild_id = "456"
channel_id = "123"
guild = %{
id: guild_id,
voice_states: [
%{user_id: bot_id, channel_id: channel_id, guild_id: guild_id, session_id: "bot"},
%{
user_id: user.discord_id,
channel_id: channel_id,
guild_id: guild_id,
session_id: "abc"
}
]
}
{:ok, recorder} = Agent.start_link(fn -> [] end)
capture_log(fn ->
with_mocks([
{Soundboard.Discord.GuildCache, [], [all: fn -> [guild] end]},
{Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: bot_id}} end]},
{Soundboard.AudioPlayer, [],
[
play_sound: fn filename, played_by ->
Agent.update(recorder, &(&1 ++ [{:play_sound, filename, played_by}]))
:ok
end
]}
]) do
payload = %{
channel_id: channel_id,
guild_id: guild_id,
user_id: user.discord_id,
session_id: "abc"
}
Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})
assert Agent.get(recorder, & &1) == [{:play_sound, "join.mp3", "System"}]
end
end)
end
test "plays leave sounds before auto-leaving the voice channel" do
user = insert_user!(%{discord_id: "556", username: "leaver"})
sound = insert_sound!(user, %{filename: "leave.mp3"})
insert_user_sound_setting!(user, sound, %{is_leave_sound: true})
bot_id = "999"
guild_id = "456"
channel_id = "123"
guild = %{
id: guild_id,
voice_states: [
%{user_id: bot_id, channel_id: channel_id, guild_id: guild_id, session_id: "bot"}
]
}
{:ok, recorder} = Agent.start_link(fn -> [] end)
capture_log(fn ->
with_mocks([
{Soundboard.Discord.GuildCache, [],
[
all: fn -> [guild] end,
get: fn ^guild_id -> {:ok, guild} end
]},
{Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: bot_id}} end]},
{Soundboard.AudioPlayer, [],
[
play_sound: fn filename, played_by ->
Agent.update(recorder, &(&1 ++ [{:play_sound, filename, played_by}]))
:ok
end,
last_user_left: fn guild ->
Agent.update(recorder, &(&1 ++ [{:last_user_left, guild}]))
:ok
end
]}
]) do
payload = %{
channel_id: nil,
guild_id: guild_id,
user_id: user.discord_id,
session_id: "gone"
}
Handler.handle_event({:VOICE_STATE_UPDATE, payload, nil})
assert Agent.get(recorder, & &1) == [
{:play_sound, "leave.mp3", "System"},
{:last_user_left, guild_id}
]
end
end)
end
test "schedules runtime follow-up messages from the handler boundary" do
payload = %{channel_id: "123", guild_id: "456", user_id: "999", session_id: "abc"}
with_mocks([
{Soundboard.Discord.Handler.VoiceRuntime, [],
[
bot_user?: fn _ -> true end,
handle_connect: fn ^payload ->
[{:schedule_recheck_alone, "456", "123", 0}]
end
]}
]) do
assert {:noreply, nil} =
Handler.handle_cast({:eda_event, {:VOICE_STATE_UPDATE, payload, nil}}, nil)
assert_receive {:recheck_alone, "456", "123"}
end
end
test "voice commands update the audio player once after the Discord call succeeds" do
guild_id = "456"
channel_id = "123"
user_id = "777"
guild = %{
id: guild_id,
voice_states: [
%{user_id: user_id, channel_id: channel_id, guild_id: guild_id, session_id: "voice"}
]
}
{:ok, recorder} = Agent.start_link(fn -> [] end)
capture_log(fn ->
with_mocks([
{Soundboard.Discord.GuildCache, [],
[get!: fn ^guild_id -> guild end, get: fn ^guild_id -> {:ok, guild} end]},
{Soundboard.Discord.BotIdentity, [], [fetch: fn -> {:ok, %{id: "999"}} end]},
{Soundboard.Discord.Message, [], [create: fn _, _ -> :ok end]},
{Soundboard.Discord.Voice, [],
[
join_channel: fn ^guild_id, ^channel_id ->
Agent.update(recorder, &(&1 ++ [{:join_channel, guild_id, channel_id}]))
:ok
end,
leave_channel: fn ^guild_id ->
Agent.update(recorder, &(&1 ++ [{:leave_channel, guild_id}]))
:ok
end
]},
{Soundboard.AudioPlayer, [],
[
set_voice_channel: fn guild, channel ->
Agent.update(recorder, &(&1 ++ [{:set_voice_channel, guild, channel}]))
:ok
end
]}
]) do
Handler.handle_event({
:MESSAGE_CREATE,
%{content: "!join", guild_id: guild_id, channel_id: "text", author: %{id: user_id}},
nil
})
Handler.handle_event({
:MESSAGE_CREATE,
%{content: "!leave", guild_id: guild_id, channel_id: "text", author: %{id: user_id}},
nil
})
assert Agent.get(recorder, & &1) == [
{:join_channel, guild_id, channel_id},
{:set_voice_channel, guild_id, channel_id},
{:leave_channel, guild_id},
{:set_voice_channel, nil, nil}
]
end
end)
end
end
defp insert_user!(attrs) do
%User{}
|> User.changeset(Map.put_new(attrs, :avatar, "avatar.png"))
|> Repo.insert!()
end
defp insert_sound!(user, attrs) do
attrs =
attrs
|> Map.put_new(:user_id, user.id)
|> Map.put_new(:source_type, "local")
|> Map.put_new(:volume, 1.0)
%Sound{}
|> Sound.changeset(attrs)
|> Repo.insert!()
end
defp insert_user_sound_setting!(user, sound, attrs) do
attrs =
attrs
|> Map.put(:user_id, user.id)
|> Map.put(:sound_id, sound.id)
%UserSoundSetting{}
|> UserSoundSetting.changeset(attrs)
|> Repo.insert!()
end
end
================================================
FILE: test/soundboard_web/eda_consumer_test.exs
================================================
defmodule Soundboard.Discord.ConsumerTest do
use ExUnit.Case, async: false
alias Soundboard.Discord.{Consumer, Handler}
setup do
on_exit(fn ->
if Process.whereis(Handler) == self() do
Process.unregister(Handler)
end
end)
:ok
end
test "dispatches events through the DiscordHandler GenServer boundary" do
Process.register(self(), Handler)
assert :ok = Consumer.handle_event({:READY, %{id: "1"}})
assert_receive {:"$gen_cast", {:eda_event, {:READY, %{id: "1"}, nil}}}
end
test "returns error when the DiscordHandler is unavailable" do
refute Process.whereis(Handler)
assert :error = Consumer.handle_event({:READY, %{id: "1"}})
end
end
================================================
FILE: test/soundboard_web/live/favorites_live_test.exs
================================================
defmodule SoundboardWeb.FavoritesLiveTest do
use SoundboardWeb.ConnCase
import Phoenix.LiveViewTest
import Mock
alias Soundboard.Accounts.User
alias Soundboard.{Favorites, Repo, Sound}
alias SoundboardWeb.SoundHelpers
setup %{conn: conn} do
Repo.delete_all(Sound)
Repo.delete_all(User)
{:ok, user} =
%User{}
|> User.changeset(%{
username: "favorite_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "favorite.jpg"
})
|> Repo.insert()
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "favorite_#{System.unique_integer([:positive])}.mp3",
user_id: user.id,
source_type: "local"
})
|> Repo.insert()
{:ok, second_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "favorite_extra_#{System.unique_integer([:positive])}.mp3",
user_id: user.id,
source_type: "local"
})
|> Repo.insert()
{:ok, _favorite} = Favorites.toggle_favorite(user.id, sound.id)
authed_conn =
conn
|> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{user_id: user.id})
%{conn: authed_conn, user: user, sound: sound, second_sound: second_sound}
end
test "redirects unauthenticated users", _context do
conn =
build_conn()
|> get("/favorites")
assert redirected_to(conn) == "/auth/discord"
end
test "renders favorite sounds for the current user", %{conn: conn, sound: sound} do
{:ok, _view, html} = live(conn, "/favorites")
assert html =~ "Favorites"
assert html =~ SoundHelpers.display_name(sound.filename)
end
test "plays a favorite sound", %{conn: conn, user: user, sound: sound} do
{:ok, view, _html} = live(conn, "/favorites")
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
view
|> element("[phx-click='play'][phx-value-name='#{sound.filename}']")
|> render_click()
assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))
end
end
test "toggle_favorite removes a sound from favorites", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/favorites")
html =
view
|> element("[phx-click='toggle_favorite'][phx-value-sound-id='#{sound.id}']")
|> render_click()
assert html =~ "Favorites updated!"
assert html =~ "You currently have no favorites"
end
test "files_updated refreshes the favorites list", %{
conn: conn,
user: user,
second_sound: second_sound
} do
{:ok, view, _html} = live(conn, "/favorites")
{:ok, _favorite} = Favorites.toggle_favorite(user.id, second_sound.id)
send(view.pid, {:files_updated})
assert render(view) =~ SoundHelpers.display_name(second_sound.filename)
end
test "stats_updated refreshes the favorites list", %{
conn: conn,
user: user,
second_sound: second_sound
} do
{:ok, view, _html} = live(conn, "/favorites")
{:ok, _favorite} = Favorites.toggle_favorite(user.id, second_sound.id)
send(view.pid, {:stats_updated})
assert render(view) =~ SoundHelpers.display_name(second_sound.filename)
end
end
================================================
FILE: test/soundboard_web/live/settings_live_test.exs
================================================
defmodule SoundboardWeb.SettingsLiveTest do
use SoundboardWeb.ConnCase
import Phoenix.LiveViewTest
alias Soundboard.Accounts.{ApiTokens, User}
alias Soundboard.Repo
setup %{conn: conn} do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "apitok_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "test.jpg"
})
|> Repo.insert()
authed_conn =
conn
|> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{user_id: user.id})
%{conn: authed_conn, user: user}
end
test "can create and revoke tokens via live view", %{conn: conn} do
{:ok, view, _html} = live(conn, "/settings")
# Create token
view
|> element("form[phx-submit=\"create_token\"]")
|> render_submit(%{"label" => "CI Bot"})
# Ensure it appears in the table
html = render(view)
assert html =~ "CI Bot"
# Revoke the first token button
view
|> element("button", "Revoke")
|> render_click()
# Should disappear from the table
refute has_element?(view, "td", "CI Bot")
end
test "shows persisted tokens after reload", %{conn: conn, user: user} do
{:ok, raw, _token} = ApiTokens.generate_token(user, %{label: "Saved token"})
{:ok, _view, html} = live(conn, "/settings")
assert html =~ "Saved token"
assert html =~ raw
end
test "shows upload API documentation", %{conn: conn} do
{:ok, _view, html} = live(conn, "/settings")
assert html =~ "POST /api/sounds"
assert html =~ "Upload local file (multipart/form-data)"
assert html =~ "Upload from URL (JSON)"
assert html =~ "tags[]"
assert html =~ "is_join_sound"
assert html =~ "is_leave_sound"
end
end
================================================
FILE: test/soundboard_web/live/soundboard_live/edit_flow_test.exs
================================================
defmodule SoundboardWeb.Live.SoundboardLive.EditFlowTest do
use Soundboard.DataCase, async: true
alias Phoenix.LiveView.Socket
alias Soundboard.Accounts.User
alias Soundboard.{Sound, Tag}
alias SoundboardWeb.Live.SoundboardLive.EditFlow
test "select_tag adds a suggested tag even when it is outside the empty-search limit" do
seed_alphabetical_tags()
tag = Repo.insert!(Tag.changeset(%Tag{}, %{name: "meme"}))
user = create_user()
sound =
%Sound{}
|> Sound.changeset(%{filename: "test.mp3", source_type: "local", user_id: user.id})
|> Repo.insert!()
|> Repo.preload(:tags)
socket = %Socket{assigns: %{__changed__: %{}, current_sound: sound}}
assert {:noreply, updated_socket} = EditFlow.select_tag(socket, "meme")
assert Enum.any?(updated_socket.assigns.current_sound.tags, &(&1.id == tag.id))
assert updated_socket.assigns.tag_input == ""
assert updated_socket.assigns.tag_suggestions == []
end
defp create_user do
%User{}
|> User.changeset(%{
username: "testuser",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "avatar.png"
})
|> Repo.insert!()
end
defp seed_alphabetical_tags do
for name <- ~w(a b c d e f g h i j) do
Repo.insert!(Tag.changeset(%Tag{}, %{name: name}))
end
end
end
================================================
FILE: test/soundboard_web/live/soundboard_live/upload_flow_test.exs
================================================
defmodule SoundboardWeb.Live.SoundboardLive.UploadFlowTest do
use Soundboard.DataCase, async: true
alias Phoenix.LiveView.Socket
alias Soundboard.Tag
alias SoundboardWeb.Live.SoundboardLive.UploadFlow
test "select_tag adds a suggested tag even when it is outside the empty-search limit" do
seed_alphabetical_tags()
Repo.insert!(Tag.changeset(%Tag{}, %{name: "meme"}))
socket = build_socket(%{current_sound: nil, upload_tags: []})
assert {:noreply, updated_socket} = UploadFlow.select_tag(socket, "meme")
assert Enum.map(updated_socket.assigns.upload_tags, & &1.name) == ["meme"]
assert updated_socket.assigns.upload_tag_input == ""
assert updated_socket.assigns.upload_tag_suggestions == []
end
test "save treats consume_uploaded_entries success results as a successful upload" do
socket = build_socket(%{show_upload_modal: true})
consume_uploaded_entries_fn = fn _socket, :audio, _fun ->
[{:ok, %{id: 123}}]
end
assert {:noreply, updated_socket} = UploadFlow.save(socket, %{}, consume_uploaded_entries_fn)
assert updated_socket.assigns.show_upload_modal == false
assert updated_socket.assigns.flash["info"] == "Sound added successfully"
assert is_list(updated_socket.assigns.uploaded_files)
end
test "save shows upload errors returned by consume_uploaded_entries" do
socket = build_socket(%{show_upload_modal: true})
changeset =
%Ecto.Changeset{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:filename, "can't be blank")
consume_uploaded_entries_fn = fn _socket, :audio, _fun ->
[{:error, changeset}]
end
assert {:noreply, updated_socket} = UploadFlow.save(socket, %{}, consume_uploaded_entries_fn)
assert updated_socket.assigns.show_upload_modal == true
assert updated_socket.assigns.flash["error"] == "filename can't be blank"
end
defp build_socket(overrides) do
%Socket{
assigns:
Map.merge(
%{
__changed__: %{},
flash: %{},
current_sound: nil,
show_upload_modal: false,
source_type: "local",
upload_name: "",
url: "",
upload_tags: [],
upload_tag_input: "",
upload_tag_suggestions: [],
is_join_sound: false,
is_leave_sound: false,
upload_error: nil,
upload_volume: 100
},
overrides
),
private: %{live_temp: %{flash: %{}}}
}
end
defp seed_alphabetical_tags do
for name <- ~w(a b c d e f g h i j) do
Repo.insert!(Tag.changeset(%Tag{}, %{name: name}))
end
end
end
================================================
FILE: test/soundboard_web/live/soundboard_live_test.exs
================================================
defmodule SoundboardWeb.SoundboardLiveTest do
@moduledoc false
use SoundboardWeb.ConnCase
import Phoenix.LiveViewTest
alias Soundboard.{Accounts.User, Repo, Sound, Tag}
import Mock
setup %{conn: conn} do
Repo.delete_all(Sound)
Repo.delete_all(User)
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser",
discord_id: "123",
avatar: "test.jpg"
})
|> Repo.insert()
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
conn = conn |> init_test_session(%{user_id: user.id})
{:ok, conn: conn, user: user, sound: sound}
end
describe "Soundboard LiveView" do
test "mounts successfully with user session", %{conn: conn} do
{:ok, _, html} = live(conn, "/")
assert html =~ "Soundboard"
# Check for the main content instead of a specific container
assert html =~ "SoundBored"
end
test "can search sounds", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view
|> element("form")
|> render_change(%{"query" => "test"})
rendered = render(view)
assert rendered =~ "test.mp3"
end
test "can play sound", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/")
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
rendered =
view
|> element("[phx-click='play'][phx-value-name='#{sound.filename}']")
|> render_click()
assert rendered =~ sound.filename
end
end
test "play random respects current search results", %{conn: conn, user: user} do
%Sound{}
|> Sound.changeset(%{
filename: "filtered.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert!()
{:ok, view, _html} = live(conn, "/")
view
|> element("form")
|> render_change(%{"query" => "filtered"})
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
view
|> element("[phx-click='play_random']")
|> render_click()
assert_called(Soundboard.AudioPlayer.play_sound("filtered.mp3", :_))
end
end
test "play random respects selected tags", %{conn: conn, user: user} do
tag =
%Tag{}
|> Tag.changeset(%{name: "funny"})
|> Repo.insert!()
%Sound{}
|> Sound.changeset(%{
filename: "funny.mp3",
source_type: "local",
user_id: user.id,
tags: [tag]
})
|> Repo.insert!()
{:ok, view, _html} = live(conn, "/")
view
|> element("div.hidden.sm\\:flex button[phx-value-tag='funny']")
|> render_click()
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
view
|> element("[phx-click='play_random']")
|> render_click()
assert_called(Soundboard.AudioPlayer.play_sound("funny.mp3", :_))
end
end
test "can open and close upload modal", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# First verify we can see the Add Sound button
assert render(view) =~ "Add Sound"
# Click the Add Sound button and verify modal appears
view
|> element("[phx-click='show_upload_modal']")
|> render_click()
# The modal should be visible now, verify its presence using form ID and content
assert has_element?(view, "#upload-form")
assert has_element?(view, "form[phx-submit='save_upload']")
assert has_element?(view, "select[name='source_type']")
assert render(view) =~ "Source Type"
# Close the modal using the correct phx-click value
view
|> element("[phx-click='close_upload_modal']")
|> render_click()
# Verify modal is gone by checking for the form
refute has_element?(view, "#upload-form")
end
test "can edit sound", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/")
rendered =
view
|> element("[phx-click='edit'][phx-value-id='#{sound.id}']")
|> render_click()
assert rendered =~ "Edit Sound"
params = %{
"filename" => "updated",
"source_type" => "local",
"volume" => "80"
}
uploads_dir = uploads_dir()
File.mkdir_p!(uploads_dir)
test_file = Path.join(uploads_dir, "test.mp3")
updated_file = Path.join(uploads_dir, "updated.mp3")
unless File.exists?(test_file) do
File.write!(test_file, "test content")
end
# Target the edit form specifically
view
|> element("#edit-form")
|> render_submit(params)
# Clean up both original and updated files
File.rm_rf!(test_file)
File.rm_rf!(updated_file)
updated_sound = Repo.get(Sound, sound.id)
assert updated_sound.filename == "updated.mp3"
assert_in_delta updated_sound.volume, 0.8, 0.0001
end
test "shared sounds can be edited by any signed-in user but only deleted by the uploader", %{
conn: conn
} do
{:ok, other_user} =
%User{}
|> User.changeset(%{
username: "other_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "other.jpg"
})
|> Repo.insert()
{:ok, other_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "other-owned.mp3",
source_type: "local",
user_id: other_user.id
})
|> Repo.insert()
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, "[phx-click='edit'][phx-value-id='#{other_sound.id}']")
rendered =
view
|> element("[phx-click='edit'][phx-value-id='#{other_sound.id}']")
|> render_click()
assert rendered =~ "Edit Sound"
refute rendered =~ "Delete Sound"
end
test "edit validation preserves the current sound extension when checking duplicates", %{
conn: conn,
user: user
} do
{:ok, current_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "current.wav",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
{:ok, _existing_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "taken.wav",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='edit'][phx-value-id='#{current_sound.id}']")
|> render_click()
view
|> element("#edit-form")
|> render_change(%{
"_target" => ["filename"],
"sound_id" => current_sound.id,
"filename" => "taken"
})
assert render(view) =~ "A sound with that name already exists"
end
test "slider volume change persists on save", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='edit'][phx-value-id='#{sound.id}']")
|> render_click()
render_hook(view, :update_volume, %{"volume" => 27, "target" => "edit"})
base_filename = Path.rootname(sound.filename)
view
|> element("#edit-form")
|> render_submit(%{
"filename" => base_filename,
"source_type" => sound.source_type,
"volume" => "27"
})
updated_sound = Repo.get!(Sound, sound.id)
assert_in_delta updated_sound.volume, 0.27, 0.0001
end
test "can delete sound", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/")
uploads_dir = uploads_dir()
test_file = Path.join(uploads_dir, "test.mp3")
File.mkdir_p!(uploads_dir)
File.write!(test_file, "test content")
view
|> element("[phx-click='edit'][phx-value-id='#{sound.id}']")
|> render_click()
view
|> element("[phx-click='show_delete_confirm']")
|> render_click()
view
|> element("[phx-click='delete_sound']")
|> render_click()
File.rm_rf!(test_file)
assert Repo.get(Sound, sound.id) == nil
end
test "url upload allows setting url before name", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='show_upload_modal']")
|> render_click()
view
|> element("select[name='source_type']")
|> render_change(%{"source_type" => "url"})
html =
view
|> element("#upload-form")
|> render_change(%{"url" => "https://example.com/beep.mp3"})
refute html =~ "Please select a file"
refute html =~ "can't be blank"
assert html =~ "https://example.com/beep.mp3"
end
test "can upload sound from url", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='show_upload_modal']")
|> render_click()
view
|> element("select[name='source_type']")
|> render_change(%{"source_type" => "url"})
params = %{
"url" => "https://example.com/wow.mp3",
"name" => "wow"
}
view
|> element("#upload-form")
|> render_submit(params)
new_sound = Repo.get_by!(Sound, filename: "wow.mp3")
assert new_sound.source_type == "url"
assert new_sound.url == "https://example.com/wow.mp3"
assert new_sound.user_id == user.id
Repo.delete!(new_sound)
end
test "upload sound from url saves provided volume", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='show_upload_modal']")
|> render_click()
view
|> element("select[name='source_type']")
|> render_change(%{"source_type" => "url"})
view
|> element("#upload-form")
|> render_submit(%{
"url" => "https://example.com/soft.mp3",
"name" => "soft",
"volume" => "25"
})
sound = Repo.get_by!(Sound, filename: "soft.mp3")
assert_in_delta sound.volume, 0.25, 0.0001
Repo.delete!(sound)
end
test "deleting a local sound removes the file", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/")
uploads_dir = uploads_dir()
File.mkdir_p!(uploads_dir)
sound_path = Path.join(uploads_dir, sound.filename)
File.write!(sound_path, "test content")
view
|> element("[phx-click='edit'][phx-value-id='#{sound.id}']")
|> render_click()
view
|> element("[phx-click='show_delete_confirm']")
|> render_click()
view
|> element("[phx-click='delete_sound']")
|> render_click()
refute File.exists?(sound_path)
assert Repo.get(Sound, sound.id) == nil
end
test "failed rename keeps original file", %{conn: conn, user: user, sound: sound} do
{:ok, conflict_sound} =
%Sound{}
|> Sound.changeset(%{
filename: "conflict.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
uploads_dir = uploads_dir()
File.mkdir_p!(uploads_dir)
original_path = Path.join(uploads_dir, sound.filename)
conflict_path = Path.join(uploads_dir, conflict_sound.filename)
File.write!(original_path, "original")
File.rm_rf!(conflict_path)
on_exit(fn ->
uploads_dir = uploads_dir()
File.rm_rf!(Path.join(uploads_dir, sound.filename))
File.rm_rf!(Path.join(uploads_dir, "conflict.mp3"))
end)
{:ok, view, _html} = live(conn, "/")
view
|> element("[phx-click='edit'][phx-value-id='#{sound.id}']")
|> render_click()
_html =
view
|> element("#edit-form")
|> render_submit(%{
"filename" => "conflict",
"source_type" => "local",
"url" => "",
"sound_id" => Integer.to_string(sound.id)
})
assert File.exists?(original_path)
refute File.exists?(conflict_path)
assert Repo.get!(Sound, sound.id).filename == sound.filename
end
test "handles pubsub updates", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
Soundboard.PubSubTopics.broadcast_files_updated()
# Just verify the view is still alive
assert render(view) =~ "SoundBored"
end
end
defp uploads_dir do
Soundboard.UploadsPath.dir()
end
end
================================================
FILE: test/soundboard_web/live/stats_live_test.exs
================================================
defmodule SoundboardWeb.StatsLiveTest do
@moduledoc """
Test for the StatsLive component.
"""
use SoundboardWeb.ConnCase
import Phoenix.LiveViewTest
alias Soundboard.Accounts.User
alias Soundboard.{Favorites, Repo, Sound, Stats}
alias Soundboard.Stats.Play
alias SoundboardWeb.SoundHelpers
import Mock
setup %{conn: conn} do
Repo.delete_all(Play)
Repo.delete_all(Sound)
Repo.delete_all(User)
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser",
discord_id: "123",
avatar: "test.jpg"
})
|> Repo.insert()
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test_sound_#{System.unique_integer()}.mp3",
user_id: user.id,
source_type: "local"
})
|> Repo.insert()
{:ok, _play} = Stats.track_play(sound.filename, user.id)
Favorites.toggle_favorite(user.id, sound.id)
authed_conn =
conn
|> Map.replace!(:secret_key_base, SoundboardWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{user_id: user.id})
{:ok, conn: authed_conn, user: user, sound: sound}
end
test "mounts successfully with user session", %{conn: conn, user: user, sound: sound} do
{:ok, _view, html} = live(conn, "/stats")
assert html =~ "Stats"
assert html =~ "Top Users"
assert html =~ user.username
assert html =~ SoundHelpers.display_name(sound.filename)
end
test "handles sound_played message", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/stats")
send(view.pid, {:sound_played, %{filename: sound.filename, played_by: "testuser"}})
assert render(view) =~ "testuser played #{SoundHelpers.display_name(sound.filename)}"
end
test "handles stats_updated message", %{conn: conn, sound: sound} do
{:ok, view, _html} = live(conn, "/stats")
send(view.pid, {:stats_updated})
assert render(view) =~ SoundHelpers.display_name(sound.filename)
end
test "renders renamed sounds from historical plays", %{conn: conn, sound: sound} do
renamed = "renamed_#{System.unique_integer([:positive])}.mp3"
sound
|> Sound.changeset(%{filename: renamed})
|> Repo.update!()
{:ok, _view, html} = live(conn, "/stats")
assert html =~ SoundHelpers.display_name(renamed)
end
test "handles error message", %{conn: conn} do
{:ok, view, _html} = live(conn, "/stats")
send(view.pid, {:error, "Test error"})
assert render(view) =~ "Test error"
end
test "handles presence_diff broadcast", %{conn: conn} do
{:ok, view, _html} = live(conn, "/stats")
send(view.pid, %Phoenix.Socket.Broadcast{
event: "presence_diff",
payload: %{joins: %{}, leaves: %{}}
})
assert render(view) =~ "Top Users"
end
test "handles play_sound event", %{conn: conn, user: user, sound: sound} do
{:ok, view, _html} = live_as_user(conn, user)
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
html = render_click(view, "play_sound", %{"sound" => sound.filename})
assert html =~ SoundHelpers.display_name(sound.filename)
end
end
test "handles toggle_favorite event", %{conn: conn, user: user, sound: sound} do
{:ok, view, _html} = live_as_user(conn, user)
html = render_click(view, "toggle_favorite", %{"sound" => sound.filename})
assert html =~ SoundHelpers.display_name(sound.filename)
end
test "handles week navigation", %{conn: conn} do
{:ok, view, _html} = live(conn, "/stats")
html = render_click(view, "previous_week")
assert html =~ "Stats"
html = render_click(view, "next_week")
assert html =~ "Stats"
end
test "handles week picker input", %{conn: conn} do
{:ok, view, _html} = live(conn, "/stats")
target_date = Date.add(Date.utc_today(), -7)
days_since_monday = Date.day_of_week(target_date, :monday) - 1
start_date = Date.add(target_date, -days_since_monday)
week_value = Date.to_iso8601(target_date)
view
|> element("form[phx-change=\"select_week\"]")
|> render_change(%{"week" => week_value})
html = render(view)
assert html =~ Calendar.strftime(start_date, "%b %d")
end
defp live_as_user(conn, user) do
conn
|> Plug.Test.init_test_session(%{})
|> put_session(:user_id, user.id)
|> live("/stats")
end
end
================================================
FILE: test/soundboard_web/plugs/api_auth_db_token_test.exs
================================================
defmodule SoundboardWeb.APIAuthDBTokenTest do
use SoundboardWeb.ConnCase
import Phoenix.ConnTest
import Mock
alias Soundboard.Accounts.{ApiTokens, User}
alias Soundboard.{Repo, Sound}
setup %{conn: conn} do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "apitok_user_#{System.unique_integer([:positive])}",
discord_id: Integer.to_string(System.unique_integer([:positive])),
avatar: "test.jpg"
})
|> Repo.insert()
{:ok, raw, _rec} = ApiTokens.generate_token(user, %{label: "test"})
{:ok, sound} =
%Sound{}
|> Sound.changeset(%{
filename: "test_sound_#{System.unique_integer([:positive])}.mp3",
source_type: "local",
user_id: user.id
})
|> Repo.insert()
conn = put_req_header(conn, "authorization", "Bearer " <> raw)
%{conn: conn, user: user, sound: sound}
end
test "GET /api/sounds authorized via DB token", %{conn: conn} do
conn = get(conn, ~p"/api/sounds")
assert json_response(conn, 200)["data"] |> is_list()
end
test "POST /api/sounds/:id/play authorized via DB token", %{
conn: conn,
sound: sound,
user: user
} do
# Mock the audio player so we don't actually attempt voice playback
with_mock Soundboard.AudioPlayer, play_sound: fn _, _ -> :ok end do
conn = post(conn, ~p"/api/sounds/#{sound.id}/play")
assert %{
"data" => %{
"status" => "accepted",
"sound" => %{"id" => sound_id, "filename" => filename}
}
} = json_response(conn, 202)
assert sound_id == sound.id
assert filename == sound.filename
assert_called(Soundboard.AudioPlayer.play_sound(sound.filename, user))
end
end
test "POST /api/sounds/stop authorized via DB token", %{conn: conn} do
with_mock Soundboard.AudioPlayer, stop_sound: fn -> :ok end do
conn = post(conn, ~p"/api/sounds/stop")
assert %{"data" => %{"status" => "accepted"}} = json_response(conn, 202)
end
end
test "unauthorized when token invalid", %{conn: _conn} do
conn = build_conn() |> put_req_header("authorization", "Bearer badtoken")
conn = get(conn, ~p"/api/sounds")
assert json_response(conn, 401)
end
test "returns internal server error when token bookkeeping fails", %{conn: _conn} do
with_mock Soundboard.Accounts.ApiTokens,
verify_token: fn _ -> {:error, :token_update_failed} end do
conn =
build_conn()
|> put_req_header("authorization", "Bearer anytoken")
|> get(~p"/api/sounds")
assert %{"error" => "API token verification failed"} = json_response(conn, 500)
end
end
end
================================================
FILE: test/soundboard_web/plugs/basic_auth_test.exs
================================================
defmodule SoundboardWeb.BasicAuthPlugTest do
use ExUnit.Case, async: true
import Plug.Test
import Plug.Conn
alias SoundboardWeb.Plugs.BasicAuth
setup do
previous_username = System.get_env("BASIC_AUTH_USERNAME")
previous_password = System.get_env("BASIC_AUTH_PASSWORD")
System.delete_env("BASIC_AUTH_USERNAME")
System.delete_env("BASIC_AUTH_PASSWORD")
on_exit(fn ->
restore_env("BASIC_AUTH_USERNAME", previous_username)
restore_env("BASIC_AUTH_PASSWORD", previous_password)
end)
:ok
end
defp restore_env(key, nil), do: System.delete_env(key)
defp restore_env(key, value), do: System.put_env(key, value)
# -- No credentials configured: auth disabled --
test "bypasses auth when both credentials are missing" do
conn = conn(:get, "/") |> BasicAuth.call(%{})
refute conn.halted
end
test "treats blank credentials as missing and bypasses auth" do
System.put_env("BASIC_AUTH_USERNAME", " ")
System.put_env("BASIC_AUTH_PASSWORD", "")
conn = conn(:get, "/") |> BasicAuth.call(%{})
refute conn.halted
end
# -- Partial credentials: fail closed --
test "fails closed when only username is configured" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.delete_env("BASIC_AUTH_PASSWORD")
conn = conn(:get, "/") |> BasicAuth.call(%{})
assert conn.halted
assert conn.status == 401
end
test "fails closed when only password is configured" do
System.delete_env("BASIC_AUTH_USERNAME")
System.put_env("BASIC_AUTH_PASSWORD", "p")
conn = conn(:get, "/") |> BasicAuth.call(%{})
assert conn.halted
assert conn.status == 401
end
# -- Both credentials configured: authenticate --
test "authorizes with valid Basic header" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.put_env("BASIC_AUTH_PASSWORD", "p")
header = "Basic " <> Base.encode64("u:p")
conn =
conn(:get, "/")
|> put_req_header("authorization", header)
|> BasicAuth.call(%{})
refute conn.halted
end
test "authorizes when password contains a colon" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.put_env("BASIC_AUTH_PASSWORD", "p:extra")
header = "Basic " <> Base.encode64("u:p:extra")
conn =
conn(:get, "/")
|> put_req_header("authorization", header)
|> BasicAuth.call(%{})
refute conn.halted
end
test "rejects with 401 when no auth header provided" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.put_env("BASIC_AUTH_PASSWORD", "p")
conn = conn(:get, "/") |> BasicAuth.call(%{})
assert conn.halted
assert conn.status == 401
assert get_resp_header(conn, "www-authenticate") == [~s(Basic realm="Soundboard")]
assert conn.resp_body == "Unauthorized"
end
test "rejects with 401 when credentials are wrong" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.put_env("BASIC_AUTH_PASSWORD", "p")
header = "Basic " <> Base.encode64("wrong:creds")
conn =
conn(:get, "/")
|> put_req_header("authorization", header)
|> BasicAuth.call(%{})
assert conn.halted
assert conn.status == 401
end
test "rejects with 401 when auth header is malformed" do
System.put_env("BASIC_AUTH_USERNAME", "u")
System.put_env("BASIC_AUTH_PASSWORD", "p")
conn =
conn(:get, "/")
|> put_req_header("authorization", "Bearer token123")
|> BasicAuth.call(%{})
assert conn.halted
assert conn.status == 401
end
end
================================================
FILE: test/soundboard_web/plugs/role_check_test.exs
================================================
defmodule SoundboardWeb.Plugs.RoleCheckTest do
use SoundboardWeb.ConnCase, async: false
import Mock
alias Soundboard.Accounts.User
alias Soundboard.Discord.RoleChecker
alias Soundboard.Repo
alias SoundboardWeb.Plugs.RoleCheck
setup do
previous_guild = Application.get_env(:soundboard, :required_guild_id)
previous_roles = Application.get_env(:soundboard, :required_role_ids)
previous_interval = Application.get_env(:soundboard, :role_recheck_interval_seconds)
on_exit(fn ->
restore_env(:required_guild_id, previous_guild)
restore_env(:required_role_ids, previous_roles)
restore_env(:role_recheck_interval_seconds, previous_interval)
end)
{:ok, user: insert_user()}
end
defp restore_env(key, nil), do: Application.delete_env(:soundboard, key)
defp restore_env(key, value), do: Application.put_env(:soundboard, key, value)
defp insert_user do
{:ok, user} =
%User{}
|> User.changeset(%{
username: "testuser#{System.unique_integer([:positive])}",
discord_id: "discord_#{System.unique_integer([:positive])}",
avatar: "avatar.jpg"
})
|> Repo.insert()
user
end
defp build_conn_with_session(conn, user, session_params) do
conn
|> init_test_session(session_params)
|> fetch_session()
|> fetch_flash()
|> assign(:current_user, user)
end
test "passes through when no current_user is assigned", %{conn: conn} do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
with_mock RoleChecker,
feature_enabled?: fn -> true end,
authorized?: fn _ -> flunk("should not be called") end do
result =
conn
|> init_test_session(%{})
|> fetch_session()
|> fetch_flash()
|> RoleCheck.call(RoleCheck.init([]))
refute result.halted
end
end
describe "feature disabled" do
test "passes through without calling authorized? when feature is disabled", %{
conn: conn,
user: user
} do
with_mock RoleChecker,
feature_enabled?: fn -> false end,
authorized?: fn _ -> flunk("should not be called") end do
result =
conn
|> build_conn_with_session(user, %{user_id: user.id})
|> RoleCheck.call(RoleCheck.init([]))
refute result.halted
end
end
end
describe "fresh timestamp" do
setup do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
:ok
end
test "passes through without calling authorized? when roles_verified_at is fresh", %{
conn: conn,
user: user
} do
fresh_ts = System.system_time(:second)
with_mock RoleChecker,
feature_enabled?: fn -> true end,
authorized?: fn _ -> flunk("should not be called") end do
result =
conn
|> build_conn_with_session(user, %{
user_id: user.id,
roles_verified_at: fresh_ts
})
|> RoleCheck.call(RoleCheck.init([]))
refute result.halted
end
end
end
describe "missing timestamp" do
setup do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
:ok
end
test "triggers re-check and updates session when authorized and roles_verified_at is absent",
%{conn: conn, user: user} do
with_mock RoleChecker,
feature_enabled?: fn -> true end,
authorized?: fn _discord_id -> true end do
result =
conn
|> build_conn_with_session(user, %{user_id: user.id})
|> RoleCheck.call(RoleCheck.init([]))
refute result.halted
assert is_integer(get_session(result, :roles_verified_at))
assert_called(RoleChecker.authorized?(user.discord_id))
end
end
end
describe "stale timestamp" do
setup do
Application.put_env(:soundboard, :required_guild_id, "g1")
Application.put_env(:soundboard, :required_role_ids, ["r1"])
:ok
end
test "triggers re-check and updates session when authorized and roles_verified_at is stale",
%{
conn: conn,
user: user
} do
stale_ts = System.system_time(:second) - 999
with_mock RoleChecker,
feature_enabled?: fn -> true end,
authorized?: fn _discord_id -> true end do
result =
conn
|> build_conn_with_session(user, %{
user_id: user.id,
roles_verified_at: stale_ts
})
|> RoleCheck.call(RoleCheck.init([]))
refute result.halted
new_ts = get_session(result, :roles_verified_at)
assert is_integer(new_ts)
assert new_ts > stale_ts
assert_called(RoleChecker.authorized?(user.discord_id))
end
end
test "clears session, redirects, and halts when unauthorized", %{conn: conn, user: user} do
stale_ts = System.system_time(:second) - 999
with_mock RoleChecker,
feature_enabled?: fn -> true end,
authorized?: fn _discord_id -> false end do
result =
conn
|> build_conn_with_session(user, %{
user_id: user.id,
roles_verified_at: stale_ts
})
|> RoleCheck.call(RoleCheck.init([]))
assert result.halted
assert redirected_to(result) == "/"
assert Phoenix.Flash.get(result.assigns.flash, :error) == "Error signing in"
refute get_session(result, :user_id)
assert_called(RoleChecker.authorized?(user.discord_id))
end
end
end
end
================================================
FILE: test/soundboard_web/presence_handler_test.exs
================================================
defmodule SoundboardWeb.PresenceHandlerTest do
use ExUnit.Case, async: false
import Mock
alias SoundboardWeb.PresenceHandler
setup do
:persistent_term.put(:user_colors, %{})
on_exit(fn ->
:persistent_term.erase(:user_colors)
end)
:ok
end
test "start_link/1 returns the named server" do
pid =
case PresenceHandler.start_link([]) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
assert Process.alive?(pid)
end
test "init/1 resets the color cache" do
:persistent_term.put(:user_colors, %{"stale" => "color"})
assert {:ok, %{}} = PresenceHandler.init(:ok)
assert :persistent_term.get(:user_colors) == %{}
end
test "get_user_color/1 returns stable assignments per user" do
first = PresenceHandler.get_user_color("alice")
second = PresenceHandler.get_user_color("alice")
third = PresenceHandler.get_user_color("bob")
assert first == second
assert is_binary(third)
refute third == ""
refute Map.equal?(:persistent_term.get(:user_colors), %{})
end
test "track_presence/2 tracks connected users and anonymous visitors" do
test_pid = self()
with_mock SoundboardWeb.Presence,
track: fn pid, topic, socket_id, payload ->
send(test_pid, {:tracked, pid, topic, socket_id, payload})
:ok
end,
list: fn _topic -> %{} end do
socket = %Phoenix.LiveView.Socket{id: "abcdef123", transport_pid: self()}
user = %{username: "alice", avatar: "avatar.png"}
assert :ok = PresenceHandler.track_presence(socket, user)
assert_receive {:tracked, _pid, "soundboard:presence", "abcdef123",
%{user: %{username: "alice", avatar: "avatar.png", color: color}}}
assert is_binary(color)
anonymous_socket = %Phoenix.LiveView.Socket{id: "anon999", transport_pid: self()}
assert :ok = PresenceHandler.track_presence(anonymous_socket, nil)
assert_receive {:tracked, _pid, "soundboard:presence", "anon999",
%{user: %{username: "Anonymous anon99", avatar: nil}}}
end
end
test "track_presence/2 is a no-op for disconnected sockets" do
with_mock SoundboardWeb.Presence, track: fn _, _, _, _ -> flunk("should not track") end do
socket = %Phoenix.LiveView.Socket{id: "offline", transport_pid: nil}
assert PresenceHandler.track_presence(socket, nil) == nil
end
end
test "get_presence_count/0 and handle_presence_diff/2 count only active presences" do
now = System.system_time(:second)
presences = %{
"fresh" => %{metas: [%{online_at: now - 10}]},
"stale" => %{metas: [%{online_at: now - 120}]},
"empty" => %{metas: []}
}
with_mock SoundboardWeb.Presence, list: fn _topic -> presences end do
assert PresenceHandler.get_presence_count() == 1
end
diff = %{
joins: %{
"joiner" => %{metas: [%{online_at: now - 5}]}
},
leaves: %{
"old" => %{metas: [%{online_at: now - 300}]},
"recent" => %{metas: [%{online_at: now - 5}]}
}
}
assert PresenceHandler.handle_presence_diff(diff, 2) == 2
assert PresenceHandler.handle_presence_diff(diff, 0) == 0
end
end
================================================
FILE: test/soundboard_web/sound_helpers_test.exs
================================================
defmodule SoundboardWeb.SoundHelpersTest do
use ExUnit.Case, async: true
alias SoundboardWeb.SoundHelpers
describe "display_name/1" do
test "strips extension and directories" do
assert SoundHelpers.display_name("priv/static/uploads/beep.mp3") == "beep"
end
test "handles values without extension" do
assert SoundHelpers.display_name("wow") == "wow"
end
test "handles nil" do
assert SoundHelpers.display_name(nil) == ""
end
test "stringifies non-binary values" do
assert SoundHelpers.display_name(123) == "123"
end
end
describe "slugify/1" do
test "converts filename to lower-case slug" do
assert SoundHelpers.slugify("Wow Sound.MP3") == "wow-sound"
end
test "falls back to default" do
assert SoundHelpers.slugify(nil) == "sound"
end
end
end
================================================
FILE: test/soundboard_web/soundboard/sound_filter_test.exs
================================================
defmodule SoundboardWeb.Soundboard.SoundFilterTest do
use ExUnit.Case, async: true
alias SoundboardWeb.Soundboard.SoundFilter
describe "filter_sounds/3" do
test "keeps sounds matching every selected tag" do
alpha = %{id: 1, name: "alpha"}
beta = %{id: 2, name: "beta"}
sounds = [
%{filename: "alpha-beta.mp3", tags: [alpha, beta]},
%{filename: "alpha-only.mp3", tags: [alpha]},
%{filename: "beta-only.mp3", tags: [beta]}
]
assert [matched] = SoundFilter.filter_sounds(sounds, "", [alpha, beta])
assert matched.filename == "alpha-beta.mp3"
end
test "matches against filenames and tag names" do
alpha = %{id: 1, name: "alpha"}
reaction = %{id: 2, name: "reaction"}
sounds = [
%{filename: "victory.mp3", tags: [alpha]},
%{filename: "sad-trombone.mp3", tags: [reaction]}
]
assert [%{filename: "victory.mp3"}] = SoundFilter.filter_sounds(sounds, "victory", [])
assert [%{filename: "sad-trombone.mp3"}] =
SoundFilter.filter_sounds(sounds, "reaction", [])
end
end
end
================================================
FILE: test/support/conn_case.ex
================================================
defmodule SoundboardWeb.ConnCase do
@moduledoc false
use ExUnit.CaseTemplate
require Phoenix.LiveViewTest
@endpoint SoundboardWeb.Endpoint
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
import Phoenix.LiveViewTest
import SoundboardWeb.ConnCase
import Soundboard.TestHelpers
alias SoundboardWeb.Router.Helpers, as: Routes
# The default endpoint for testing
@endpoint SoundboardWeb.Endpoint
use SoundboardWeb, :verified_routes
end
end
setup tags do
Soundboard.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
def file_upload(lv, field, entries) do
{entries, _refs} =
Enum.reduce(entries, {[], []}, fn entry, {entries, refs} ->
ref = entry[:ref] || "phx-#{System.unique_integer()}"
entry =
Map.merge(
%{
name: "test.mp3",
content: "test",
size: 9999,
type: "audio/mpeg",
ref: ref,
done?: true
},
entry
)
{[entry | entries], [ref | refs]}
end)
entries = Enum.reverse(entries)
for entry <- entries do
Phoenix.LiveViewTest.file_input(lv, field, entry, entry.ref)
end
end
end
================================================
FILE: test/support/data_case.ex
================================================
defmodule Soundboard.DataCase do
@moduledoc false
use ExUnit.CaseTemplate
alias Ecto.Adapters.SQL.Sandbox
using do
quote do
alias Soundboard.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Soundboard.DataCase
end
end
setup tags do
pid = Sandbox.start_owner!(Soundboard.Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Sandbox.start_owner!(Soundboard.Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end
================================================
FILE: test/support/test_helpers.ex
================================================
defmodule Soundboard.TestHelpers do
@moduledoc """
Helper functions for testing.
"""
alias Soundboard.{Accounts, Repo, Sound, Tag}
def create_test_file(filename) do
test_dir = "test/support/fixtures"
File.mkdir_p!(test_dir)
path = Path.join(test_dir, filename)
File.write!(path, "test audio content")
path
end
def cleanup_test_files do
File.rm_rf!("test/support/fixtures")
end
def setup_test_socket(assigns \\ %{}) do
%Phoenix.LiveView.Socket{
assigns:
Map.merge(
%{
current_user: nil,
current_sound: nil,
uploads: %{},
flash: %{}
},
assigns
)
}
end
def setup_upload_socket(user) do
setup_test_socket(%{
current_user: user,
uploads: %{
audio: %Phoenix.LiveView.UploadConfig{
entries: [],
ref: "test-ref",
max_entries: 1,
max_file_size: 10_000_000,
chunk_size: 64_000,
chunk_timeout: 10_000,
accept: ~w(.mp3 .wav .ogg .m4a)
}
}
})
end
def setup_test_audio_file do
test_dir = "test/support/fixtures"
File.mkdir_p!(test_dir)
file_path = Path.join(test_dir, "test_sound.mp3")
File.write!(file_path, "test audio content")
file_path
end
def create_user(attrs \\ %{}) do
user_attrs =
Enum.into(attrs, %{
username: "testuser",
discord_id: "123456789",
avatar: "test_avatar.jpg"
})
%Soundboard.Accounts.User{}
|> Accounts.User.changeset(user_attrs)
|> Soundboard.Repo.insert()
end
def create_sound(user, attrs \\ %{}) do
attrs =
Map.merge(
%{
name: "test_sound#{System.unique_integer()}",
file_path: setup_test_audio_file(),
user_id: user.id
},
attrs
)
%Sound{}
|> Sound.changeset(attrs)
|> Repo.insert()
end
def create_tag(name) when is_binary(name) do
%Tag{}
|> Tag.changeset(%{name: name})
|> Repo.insert()
end
end
================================================
FILE: test/test_helper.exs
================================================
ExUnit.start()
Application.ensure_all_started(:soundboard)
Ecto.Adapters.SQL.Sandbox.mode(Soundboard.Repo, :manual)