Files
NtR-soudcloud-fetcher/plugins/sopel/ntr_playlist.py
cottongin d6d5ac10e6 fix: separate bot vs viewer WebSocket connections, add client identification
The dashboard's own WS connection was being counted as a bot subscriber,
causing "1 bot connected" with no bots actually present. Now WS clients
send a role ("bot" or "viewer") in the subscribe message. Only bots count
toward the subscriber total. Bot plugins also send a configurable client_id
so the dashboard shows which specific bots are connected.

Made-with: Cursor
2026-03-12 07:51:55 -04:00

351 lines
11 KiB
Python

"""
ntr_playlist.py - Sopel plugin for NtR SoundCloud Fetcher API
"""
from __future__ import annotations
import json
import logging
import threading
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo
from sopel import plugin
from sopel.config import types
LOGGER = logging.getLogger(__name__)
class NtrPlaylistSection(types.StaticSection):
api_base_url = types.ValidatedAttribute("api_base_url", default="http://127.0.0.1:8000")
admin_token = types.ValidatedAttribute("admin_token", default="")
admin_nicks = types.ListAttribute("admin_nicks")
display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York")
ws_url = types.ValidatedAttribute("ws_url", default="ws://127.0.0.1:8000/ws/announce")
announce_channel = types.ValidatedAttribute("announce_channel", default="#sewerchat")
client_id = types.ValidatedAttribute("client_id", default="sopel")
_ws_stop = None
_ws_thread = None
def setup(bot):
global _ws_stop, _ws_thread
bot.settings.define_section("ntr_playlist", NtrPlaylistSection)
_ws_stop = threading.Event()
_ws_thread = threading.Thread(target=_ws_listener, args=(bot,), daemon=True)
_ws_thread.start()
def shutdown(bot):
global _ws_stop
if _ws_stop:
_ws_stop.set()
def _ws_listener(bot):
import websocket
backoff = 5
max_backoff = 60
while not _ws_stop.is_set():
ws_url = bot.settings.ntr_playlist.ws_url
token = bot.settings.ntr_playlist.admin_token
channel = bot.settings.ntr_playlist.announce_channel
client_id = bot.settings.ntr_playlist.client_id or "sopel"
if not ws_url or not token:
LOGGER.warning("ws_url or admin_token not configured, WS listener sleeping")
_ws_stop.wait(30)
continue
ws = None
try:
ws = websocket.WebSocket()
ws.connect(ws_url, timeout=10)
ws.send(json.dumps({
"type": "subscribe",
"token": token,
"role": "bot",
"client_id": client_id,
}))
LOGGER.info("Connected to announce WebSocket at %s", ws_url)
backoff = 5
while not _ws_stop.is_set():
ws.settimeout(5)
try:
raw = ws.recv()
if not raw:
break
data = json.loads(raw)
if data.get("type") == "announce" and "message" in data:
bot.say(data["message"], channel)
LOGGER.info("Announced to %s: %s", channel, data["message"])
except websocket.WebSocketTimeoutException:
continue
except websocket.WebSocketConnectionClosedException:
break
except Exception:
LOGGER.exception("WS listener error")
finally:
if ws:
try:
ws.close()
except Exception:
pass
if not _ws_stop.is_set():
LOGGER.info("Reconnecting in %ds", backoff)
_ws_stop.wait(backoff)
backoff = min(backoff * 2, max_backoff)
def configure(config):
config.define_section("ntr_playlist", NtrPlaylistSection)
config.ntr_playlist.configure_setting("api_base_url", "API base URL:")
config.ntr_playlist.configure_setting("admin_token", "Admin token (optional):")
config.ntr_playlist.configure_setting("admin_nicks", "Admin nicks (comma-separated):")
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
url = f"{base_url.rstrip('/')}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
url = f"{base_url.rstrip('/')}{path}"
encoded = None
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
if body is not None:
encoded = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=encoded, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
if not iso_string:
return "never"
dt = datetime.fromisoformat(iso_string)
local = dt.astimezone(ZoneInfo(tz_name))
return local.strftime("%a %b %-d, %-I:%M %p %Z")
def format_track(track: dict) -> str:
pos = track.get("position", 0)
title = track.get("title", "")
artist = track.get("artist", "")
url = track.get("permalink_url", "")
return f"Song #{pos}: {title} by {artist} - {url}"
_MAX_IRC_LINE = 430
def format_playlist(data: dict) -> str:
episode = data.get("episode_number", "?")
tracks = data.get("tracks", [])
count = len(tracks)
prefix = f"Episode {episode} ({count} tracks): "
parts: list[str] = []
length = len(prefix)
for t in tracks:
entry = f"{t.get('title', '')} by {t.get('artist', '')}"
sep = ", " if parts else ""
if length + len(sep) + len(entry) + 5 > _MAX_IRC_LINE: # +5 for ", ..."
parts.append("...")
break
parts.append(entry)
length += len(sep) + len(entry)
return prefix + ", ".join(parts)
def _is_admin(bot, nick: str) -> bool:
admin_nicks = bot.settings.ntr_playlist.admin_nicks
if not admin_nicks:
return False
return nick.lower() in [n.lower() for n in admin_nicks]
@plugin.rule(r"^!(\d+)$")
def ntr_playlist_position(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url
position = trigger.group(1)
try:
data = _api_get(base_url, f"/playlist/{position}")
bot.say(format_track(data))
except ApiError as e:
LOGGER.warning("API error for !%s: %s", position, e)
bot.say(e.detail)
@plugin.command("song")
def ntr_song(bot, trigger):
raw = trigger.group(2)
if not raw or not raw.strip():
bot.say("Usage: !song <episode> <position>")
return
parts = raw.strip().split()
if len(parts) < 2:
bot.say("Usage: !song <episode> <position>")
return
try:
episode = int(parts[0])
position = int(parts[1])
except ValueError:
bot.say("Usage: !song <episode> <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as e:
LOGGER.warning("API error for !song %s %s: %s", episode, position, e)
bot.say(e.detail)
return
tracks = data.get("tracks", [])
track = next((t for t in tracks if t.get("position") == position), None)
if not track:
bot.say(f"No track at position {position} in episode {episode}")
return
bot.say(format_track(track))
@plugin.command("playlist")
def ntr_playlist(bot, trigger):
raw = trigger.group(2)
base_url = bot.settings.ntr_playlist.api_base_url
if raw and raw.strip():
try:
episode = int(raw.strip())
except ValueError:
bot.say("Usage: !playlist [episode]")
return
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
return
else:
try:
data = _api_get(base_url, "/playlist")
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
return
bot.say(format_playlist(data))
@plugin.command("lastshow")
def ntr_lastshow(bot, trigger):
raw = trigger.group(2)
if not raw or not raw.strip():
bot.say("Usage: !lastshow <position>")
return
try:
position = int(raw.strip())
except ValueError:
bot.say("Usage: !lastshow <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
shows = _api_get(base_url, "/shows?limit=2")
except ApiError as e:
LOGGER.warning("API error for !lastshow: %s", e)
bot.say(e.detail)
return
if len(shows) < 2:
bot.say("No previous show found")
return
prev_show_id = shows[1]["id"]
try:
data = _api_get(base_url, f"/shows/{prev_show_id}")
except ApiError as e:
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
bot.say(e.detail)
return
tracks = data.get("tracks", [])
track = next((t for t in tracks if t.get("position") == position), None)
if not track:
episode = data.get("episode_number", "?")
bot.say(f"No track at position {position} in episode {episode}")
return
bot.say(format_track(track))
@plugin.command("status")
def ntr_status(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, "/health")
except ApiError as e:
LOGGER.warning("API error for !status: %s", e)
bot.say(e.detail)
return
tz = bot.settings.ntr_playlist.display_timezone
status = data.get("status", "unknown")
poller = "alive" if data.get("poller_alive") else "dead"
last_fetch = format_dt(data.get("last_fetch"), tz)
count = data.get("current_week_track_count", 0)
bot.say(f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}")
@plugin.command("refresh")
def ntr_refresh(bot, trigger):
if not _is_admin(bot, trigger.nick):
bot.say("Access denied")
return
token = bot.settings.ntr_playlist.admin_token
if not token:
bot.say("Admin token not configured")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_post(base_url, "/admin/refresh", token)
except ApiError as e:
LOGGER.warning("API error for !refresh: %s", e)
bot.say(f"Refresh failed: {e.detail}")
return
count = data.get("track_count", 0)
bot.say(f"Refreshed — {count} tracks")