Add cron-driven Discord netradio relay bot
One-shot bot that joins a Discord voice channel, streams a netradio
source for a configured duration, then disconnects and exits. Meant to
be scheduled via cron.
- radio_bot.py: config load/validation, voice connect, ffmpeg stream
- run.sh: venv bootstrap + launcher, cron-safe (resolves own dir)
- radioconfig.json.template: source, channel_id, runtime_seconds, token
- install_deps_{arch,debian,raspbian,fedora}.sh: system deps per distro
- requirements.txt, README, .gitignore for local config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
parent
bfc2ccab6a
commit
abee60cfdd
|
|
@ -160,3 +160,6 @@ cython_debug/
|
|||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
|
||||
# Local bot config (contains the token) — keep the template only
|
||||
radioconfig.json
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -1,2 +1,79 @@
|
|||
# Dispy-P3BOT
|
||||
|
||||
A one-shot Discord bot that relays a **netradio stream** into a Discord voice
|
||||
channel for a fixed duration, then disconnects and exits. Built to be driven by
|
||||
**cron** — e.g. "transmit for 5 minutes every day at 6 pm".
|
||||
|
||||
## How it works
|
||||
|
||||
Each invocation logs in, joins the configured voice channel, pipes the netradio
|
||||
source through ffmpeg for `runtime_seconds`, then leaves and exits. There is no
|
||||
long-running daemon — cron controls the schedule.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- `ffmpeg` on your `PATH`
|
||||
- A Discord bot token (from the [Developer Portal](https://discord.com/developers/applications))
|
||||
|
||||
The bot needs permission to **Connect** and **Speak** in the target voice
|
||||
channel. Only the default gateway intents are used, so no privileged intents
|
||||
need to be enabled.
|
||||
|
||||
## Setup
|
||||
|
||||
Install the system dependencies (Python, ffmpeg, build tools) using the script
|
||||
for your distro:
|
||||
|
||||
```bash
|
||||
./install_deps_arch.sh # Arch Linux
|
||||
./install_deps_debian.sh # Debian / Ubuntu
|
||||
./install_deps_raspbian.sh # Raspberry Pi OS
|
||||
./install_deps_fedora.sh # Fedora (enables RPM Fusion for full ffmpeg)
|
||||
```
|
||||
|
||||
Then configure and run:
|
||||
|
||||
```bash
|
||||
cp radioconfig.json.template radioconfig.json
|
||||
# then edit radioconfig.json
|
||||
./run.sh
|
||||
```
|
||||
|
||||
The first `./run.sh` creates a `.venv/` and installs dependencies; later runs
|
||||
just start the bot.
|
||||
|
||||
## Configuration (`radioconfig.json`)
|
||||
|
||||
| Key | Description |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| `netradio_source` | URL of the stream (e.g. an Icecast/Shoutcast `.mp3`/`.aac` URL). |
|
||||
| `channel_id` | Numeric ID of the target **voice** channel. |
|
||||
| `runtime_seconds` | How long to transmit, in seconds (e.g. `300` = 5 minutes). |
|
||||
| `token` | Bot token. Optional — prefer `DISCORD_BOT_TOKEN` (see below). |
|
||||
|
||||
Get a channel ID by enabling Developer Mode in Discord
|
||||
(Settings → Advanced), then right-click a voice channel → *Copy Channel ID*.
|
||||
|
||||
### Token via environment variable (recommended)
|
||||
|
||||
`DISCORD_BOT_TOKEN` takes precedence over the `token` field, so you can keep the
|
||||
token out of the config file:
|
||||
|
||||
```bash
|
||||
export DISCORD_BOT_TOKEN="your-token-here"
|
||||
./run.sh
|
||||
```
|
||||
|
||||
`radioconfig.json` is gitignored so a token placed there won't be committed.
|
||||
|
||||
## Scheduling with cron
|
||||
|
||||
Transmit for the configured duration every day at 18:00:
|
||||
|
||||
```cron
|
||||
0 18 * * * DISCORD_BOT_TOKEN="your-token" /full/path/to/run.sh >> /full/path/to/radio.log 2>&1
|
||||
```
|
||||
|
||||
Use absolute paths — `run.sh` resolves its own directory, so cron's working
|
||||
directory does not matter.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install system dependencies for Dispy-P3BOT on Arch Linux (and derivatives).
|
||||
#
|
||||
# Installs Python + venv, pip, and ffmpeg. After this, run ./run.sh which
|
||||
# creates the virtualenv and installs the Python packages (discord.py, etc).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing dependencies with pacman ..."
|
||||
$SUDO pacman -Sy --needed --noconfirm \
|
||||
python \
|
||||
python-pip \
|
||||
ffmpeg \
|
||||
base-devel
|
||||
|
||||
echo
|
||||
echo "Done. Next steps:"
|
||||
echo " cp radioconfig.json.template radioconfig.json # then edit it"
|
||||
echo " ./run.sh"
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install system dependencies for Dispy-P3BOT on Debian / Ubuntu.
|
||||
#
|
||||
# Installs Python + venv, pip, and ffmpeg. The build tools + libffi headers
|
||||
# are there in case pip has to compile PyNaCl (the voice dependency) from
|
||||
# source. After this, run ./run.sh which creates the virtualenv and installs
|
||||
# the Python packages.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing dependencies with apt ..."
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
ffmpeg \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
python3-dev
|
||||
|
||||
echo
|
||||
echo "Done. Next steps:"
|
||||
echo " cp radioconfig.json.template radioconfig.json # then edit it"
|
||||
echo " ./run.sh"
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install system dependencies for Dispy-P3BOT on Fedora.
|
||||
#
|
||||
# Installs Python + pip and ffmpeg. Fedora's main repos only ship the
|
||||
# stripped-down "ffmpeg-free"; the full ffmpeg lives in RPM Fusion, which this
|
||||
# script enables. After this, run ./run.sh which creates the virtualenv and
|
||||
# installs the Python packages (discord.py, etc).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Enabling RPM Fusion (free) for full ffmpeg ..."
|
||||
$SUDO dnf install -y \
|
||||
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm"
|
||||
|
||||
echo "Installing dependencies with dnf ..."
|
||||
# --allowerasing lets the full ffmpeg replace ffmpeg-free if it's installed.
|
||||
$SUDO dnf install -y --allowerasing \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ffmpeg \
|
||||
gcc \
|
||||
libffi-devel \
|
||||
python3-devel
|
||||
|
||||
echo
|
||||
echo "Done. Next steps:"
|
||||
echo " cp radioconfig.json.template radioconfig.json # then edit it"
|
||||
echo " ./run.sh"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install system dependencies for Dispy-P3BOT on Raspberry Pi OS (Raspbian).
|
||||
#
|
||||
# Same apt packages as Debian. The build tools + libffi headers matter more
|
||||
# here: on ARM, pip sometimes has to compile PyNaCl (the voice dependency)
|
||||
# from source rather than using a prebuilt wheel. After this, run ./run.sh
|
||||
# which creates the virtualenv and installs the Python packages.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing dependencies with apt ..."
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
ffmpeg \
|
||||
build-essential \
|
||||
libffi-dev \
|
||||
libsodium-dev \
|
||||
python3-dev
|
||||
|
||||
echo
|
||||
echo "Done. Next steps:"
|
||||
echo " cp radioconfig.json.template radioconfig.json # then edit it"
|
||||
echo " ./run.sh"
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
"""One-shot Discord netradio relay.
|
||||
|
||||
Connects to a Discord voice channel, streams a netradio source into it for a
|
||||
configured number of seconds, then disconnects and exits. Designed to be fired
|
||||
by cron (e.g. "transmit for 5 minutes every day at 6 pm").
|
||||
|
||||
Configuration lives in ./radioconfig.json (see radioconfig.json.template).
|
||||
The bot token may live in the config as "token" or, preferably, in the
|
||||
DISCORD_BOT_TOKEN environment variable (which takes precedence).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
|
||||
CONFIG_PATH = Path(__file__).resolve().parent / "radioconfig.json"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("radio_bot")
|
||||
|
||||
# Reconnect/keep-alive flags for ffmpeg so a brief network hiccup on the
|
||||
# netradio stream doesn't kill the whole broadcast.
|
||||
FFMPEG_BEFORE_OPTIONS = (
|
||||
"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
|
||||
)
|
||||
FFMPEG_OPTIONS = "-vn"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
log.error(
|
||||
"Config not found at %s. Copy radioconfig.json.template to "
|
||||
"radioconfig.json and fill it in.",
|
||||
CONFIG_PATH,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with CONFIG_PATH.open(encoding="utf-8") as fh:
|
||||
config = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
log.error("Failed to read %s: %s", CONFIG_PATH, exc)
|
||||
sys.exit(1)
|
||||
|
||||
# Token: env var wins, config is the fallback.
|
||||
token = os.environ.get("DISCORD_BOT_TOKEN") or config.get("token")
|
||||
if not token:
|
||||
log.error(
|
||||
"No bot token. Set DISCORD_BOT_TOKEN or add \"token\" to %s.",
|
||||
CONFIG_PATH,
|
||||
)
|
||||
sys.exit(1)
|
||||
config["token"] = token
|
||||
|
||||
missing = [k for k in ("netradio_source", "channel_id") if not config.get(k)]
|
||||
if missing:
|
||||
log.error("Missing required config keys: %s", ", ".join(missing))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
config["channel_id"] = int(config["channel_id"])
|
||||
except (TypeError, ValueError):
|
||||
log.error("channel_id must be a numeric Discord channel ID.")
|
||||
sys.exit(1)
|
||||
|
||||
runtime = config.get("runtime_seconds", 300)
|
||||
try:
|
||||
config["runtime_seconds"] = int(runtime)
|
||||
except (TypeError, ValueError):
|
||||
log.error("runtime_seconds must be an integer number of seconds.")
|
||||
sys.exit(1)
|
||||
if config["runtime_seconds"] <= 0:
|
||||
log.error("runtime_seconds must be greater than 0.")
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class RadioBot(discord.Client):
|
||||
def __init__(self, config: dict) -> None:
|
||||
super().__init__(intents=discord.Intents.default())
|
||||
self.config = config
|
||||
self._exit_code = 0
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
log.info("Logged in as %s (id=%s)", self.user, self.user.id)
|
||||
try:
|
||||
await self.broadcast()
|
||||
except Exception: # noqa: BLE001 - log any failure, still exit cleanly
|
||||
log.exception("Broadcast failed")
|
||||
self._exit_code = 1
|
||||
finally:
|
||||
await self.close()
|
||||
|
||||
async def broadcast(self) -> None:
|
||||
channel = self.get_channel(self.config["channel_id"])
|
||||
if channel is None:
|
||||
channel = await self.fetch_channel(self.config["channel_id"])
|
||||
|
||||
if not isinstance(channel, discord.VoiceChannel) and not isinstance(
|
||||
channel, discord.StageChannel
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Channel {self.config['channel_id']} is not a voice/stage "
|
||||
f"channel (got {type(channel).__name__})."
|
||||
)
|
||||
|
||||
source_url = self.config["netradio_source"]
|
||||
runtime = self.config["runtime_seconds"]
|
||||
|
||||
log.info("Connecting to voice channel #%s", channel.name)
|
||||
voice = await channel.connect()
|
||||
|
||||
# Stage channels put bots in the audience by default; try to move to
|
||||
# speaker so audio is actually heard.
|
||||
if isinstance(channel, discord.StageChannel):
|
||||
try:
|
||||
await self.user_to_speaker(channel)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("Could not become stage speaker; audio may be muted.")
|
||||
|
||||
try:
|
||||
audio = discord.FFmpegPCMAudio(
|
||||
source_url,
|
||||
before_options=FFMPEG_BEFORE_OPTIONS,
|
||||
options=FFMPEG_OPTIONS,
|
||||
)
|
||||
log.info("Streaming %s for %s seconds", source_url, runtime)
|
||||
voice.play(audio)
|
||||
|
||||
# Wait for the runtime, but stop early if the stream ends on its own.
|
||||
elapsed = 0
|
||||
while voice.is_playing() and elapsed < runtime:
|
||||
await asyncio.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
log.info("Broadcast finished after %s seconds", elapsed)
|
||||
finally:
|
||||
if voice.is_playing():
|
||||
voice.stop()
|
||||
await voice.disconnect()
|
||||
|
||||
async def user_to_speaker(self, channel: discord.StageChannel) -> None:
|
||||
me = channel.guild.me
|
||||
await me.edit(suppress=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = load_config()
|
||||
bot = RadioBot(config)
|
||||
try:
|
||||
bot.run(config["token"], log_handler=None)
|
||||
except discord.LoginFailure:
|
||||
log.error("Login failed: invalid bot token.")
|
||||
sys.exit(1)
|
||||
sys.exit(bot._exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"netradio_source": "http://example-stream.example.com:8000/stream.mp3",
|
||||
"channel_id": "123456789012345678",
|
||||
"runtime_seconds": 300,
|
||||
"token": ""
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
discord.py[voice]>=2.3.0
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Convenience runner for the Discord netradio relay.
|
||||
#
|
||||
# On first run it creates a local virtualenv and installs dependencies; on
|
||||
# every run it starts the bot, which streams for the configured duration and
|
||||
# then exits. Safe to call directly from cron.
|
||||
#
|
||||
# Cron example — transmit for 5 minutes every day at 18:00:
|
||||
# 0 18 * * * /path/to/run.sh >> /path/to/radio.log 2>&1
|
||||
#
|
||||
# The bot token can be provided via the DISCORD_BOT_TOKEN environment variable
|
||||
# or the "token" field in radioconfig.json.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve the directory this script lives in, so cron's cwd doesn't matter.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
# Require ffmpeg — discord.py shells out to it for audio.
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "ERROR: ffmpeg not found on PATH. Install it (e.g. 'sudo pacman -S ffmpeg')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create the virtualenv and install deps on first run.
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
echo "Setting up virtualenv in $VENV_DIR ..."
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
"$VENV_DIR/bin/pip" install --upgrade pip >/dev/null
|
||||
"$VENV_DIR/bin/pip" install -r "$SCRIPT_DIR/requirements.txt"
|
||||
fi
|
||||
|
||||
exec "$VENV_DIR/bin/python" "$SCRIPT_DIR/radio_bot.py" "$@"
|
||||
Loading…
Reference in New Issue