From abee60cfddcbf11b8acae7c9536f431fcbc650f5 Mon Sep 17 00:00:00 2001 From: The_miro Date: Sun, 12 Jul 2026 07:23:16 +0200 Subject: [PATCH] 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 --- .gitignore | 3 + README.md | 77 +++++++++++++++++ install_deps_arch.sh | 26 ++++++ install_deps_debian.sh | 32 ++++++++ install_deps_fedora.sh | 35 ++++++++ install_deps_raspbian.sh | 33 ++++++++ radio_bot.py | 169 ++++++++++++++++++++++++++++++++++++++ radioconfig.json.template | 6 ++ requirements.txt | 1 + run.sh | 38 +++++++++ 10 files changed, 420 insertions(+) create mode 100755 install_deps_arch.sh create mode 100755 install_deps_debian.sh create mode 100755 install_deps_fedora.sh create mode 100755 install_deps_raspbian.sh create mode 100755 radio_bot.py create mode 100644 radioconfig.json.template create mode 100644 requirements.txt create mode 100755 run.sh diff --git a/.gitignore b/.gitignore index 5d381cc..62e8d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index aec2709..20dc6b7 100644 --- a/README.md +++ b/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. diff --git a/install_deps_arch.sh b/install_deps_arch.sh new file mode 100755 index 0000000..452fbc4 --- /dev/null +++ b/install_deps_arch.sh @@ -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" diff --git a/install_deps_debian.sh b/install_deps_debian.sh new file mode 100755 index 0000000..e4277ed --- /dev/null +++ b/install_deps_debian.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" diff --git a/install_deps_fedora.sh b/install_deps_fedora.sh new file mode 100755 index 0000000..f51eb07 --- /dev/null +++ b/install_deps_fedora.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" diff --git a/install_deps_raspbian.sh b/install_deps_raspbian.sh new file mode 100755 index 0000000..f6119ad --- /dev/null +++ b/install_deps_raspbian.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" diff --git a/radio_bot.py b/radio_bot.py new file mode 100755 index 0000000..869e7ac --- /dev/null +++ b/radio_bot.py @@ -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() diff --git a/radioconfig.json.template b/radioconfig.json.template new file mode 100644 index 0000000..324ed33 --- /dev/null +++ b/radioconfig.json.template @@ -0,0 +1,6 @@ +{ + "netradio_source": "http://example-stream.example.com:8000/stream.mp3", + "channel_id": "123456789012345678", + "runtime_seconds": 300, + "token": "" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..940deb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +discord.py[voice]>=2.3.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..bb2bc02 --- /dev/null +++ b/run.sh @@ -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" "$@"