170 lines
5.3 KiB
Python
Executable File
170 lines
5.3 KiB
Python
Executable File
#!/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()
|