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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.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
|
# 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