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
Amir Alexander Abdelbaki 2026-07-12 07:23:16 +02:00
parent bfc2ccab6a
commit abee60cfdd
10 changed files with 420 additions and 0 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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.

26
install_deps_arch.sh Executable file
View File

@ -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"

32
install_deps_debian.sh Executable file
View File

@ -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"

35
install_deps_fedora.sh Executable file
View File

@ -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"

33
install_deps_raspbian.sh Executable file
View File

@ -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"

169
radio_bot.py Executable file
View File

@ -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()

View File

@ -0,0 +1,6 @@
{
"netradio_source": "http://example-stream.example.com:8000/stream.mp3",
"channel_id": "123456789012345678",
"runtime_seconds": 300,
"token": ""
}

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
discord.py[voice]>=2.3.0

38
run.sh Executable file
View File

@ -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" "$@"