""" 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 LOGGER.info("WS listener thread started") 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 LOGGER.info("Connecting to %s as client_id=%s", ws_url, client_id) ws = None try: ws = websocket.WebSocket() ws.connect(ws_url, timeout=10) LOGGER.info("WebSocket TCP connection established") subscribe_msg = { "type": "subscribe", "token": token, "role": "bot", "client_id": client_id, } ws.send(json.dumps(subscribe_msg)) LOGGER.info("Sent subscribe message (role=bot, client_id=%s)", client_id) backoff = 5 while not _ws_stop.is_set(): ws.settimeout(5) try: raw = ws.recv() if not raw: LOGGER.warning("Received empty message, connection closing") break data = json.loads(raw) LOGGER.debug("Received WS message: type=%s", data.get("type")) if data.get("type") == "announce" and "message" in data: bot.say(data["message"], channel) LOGGER.info("Announced to %s: %s", channel, data["message"]) elif data.get("type") == "status": LOGGER.info( "Status update: %d bot(s) connected, clients=%s", data.get("subscribers", 0), [c.get("client_id") for c in data.get("clients", [])], ) except websocket.WebSocketTimeoutException: continue except websocket.WebSocketConnectionClosedException: LOGGER.warning("WebSocket connection closed by server") break except ConnectionRefusedError: LOGGER.warning("Connection refused at %s", ws_url) except TimeoutError: LOGGER.warning("Connection timed out to %s", ws_url) except Exception: LOGGER.exception("WS listener error") finally: if ws: try: ws.close() LOGGER.debug("WebSocket closed cleanly") 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 ") return parts = raw.strip().split() if len(parts) < 2: bot.say("Usage: !song ") return try: episode = int(parts[0]) position = int(parts[1]) except ValueError: bot.say("Usage: !song ") 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 ") return try: position = int(raw.strip()) except ValueError: bot.say("Usage: !lastshow ") 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")