""" Sopel plugin: jeeves dj Playlist manager with admin playback controls. !dj - queue a YouTube track (anyone) !np - show current track (anyone) !queue - show upcoming tracks (anyone) !loop - show loop state (anyone) !play - start/resume playback (op) !stop - pause playback (op) !skip - skip to next track (op) !remove - remove current song from queue (op) !loop - toggle queue looping (op) !clear - clear queue and stop playback (op) Track duration and queue cap are configurable via !set. """ import os as _os, sys as _sys _d = _os.path.dirname(_os.path.abspath(__file__)) if _d not in _sys.path: _sys.path.insert(0, _d) import jv_core as jv import re import threading from urllib.parse import urlparse, parse_qs from sopel import plugin _YOUTUBE_HOSTS = frozenset({ "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", }) _REJECT_PATHS = ("/shorts/", "/embed/", "/live/") _VIDEO_ID_RE = re.compile(r'^[A-Za-z0-9_-]{11}$') def setup(bot): jv.ensure_setup(bot) bot.memory.setdefault("dj_queue", []) bot.memory.setdefault("dj_pos", -1) bot.memory.setdefault("dj_playing", False) bot.memory.setdefault("dj_timer", None) bot.memory.setdefault("dj_loop", False) bot.memory.setdefault("dj_channel", None) bot.memory.setdefault("dj_lock", threading.RLock()) def _extract_video_id(url): if "://" not in url: url = "https://" + url try: p = urlparse(url) except Exception: return None if p.scheme not in ("http", "https"): return None if p.netloc.lower() not in _YOUTUBE_HOSTS: return None for bad in _REJECT_PATHS: if p.path.startswith(bad): return None if p.netloc.lower() == "youtu.be": vid = p.path.strip("/") return vid if _VIDEO_ID_RE.match(vid) else None qs = parse_qs(p.query) vid = qs.get("v", [None])[0] return vid if vid and _VIDEO_ID_RE.match(vid) else None def _normalize(video_id): return f"https://youtu.be/{video_id}" def _cancel_timer(bot): t = bot.memory.get("dj_timer") if t: t.cancel() bot.memory["dj_timer"] = None def _play_at(bot, sender, pos): queue = bot.memory["dj_queue"] duration = jv.cfg_val(bot, "dj_track_duration") bot.memory["dj_pos"] = pos bot.memory["dj_channel"] = str(sender) bot.say(f"now playing: {queue[pos]}", sender) t = threading.Timer(duration, _on_track_end, args=(bot, sender)) t.daemon = True t.start() bot.memory["dj_timer"] = t def _on_track_end(bot, sender): with bot.memory["dj_lock"]: if not bot.memory["dj_playing"]: return queue = bot.memory["dj_queue"] pos = bot.memory["dj_pos"] next_pos = pos + 1 if next_pos >= len(queue): if bot.memory["dj_loop"] and len(queue) > 0: next_pos = 0 else: bot.say("queue finished, playback stopped", sender) bot.memory["dj_playing"] = False bot.memory["dj_pos"] = -1 bot.memory["dj_timer"] = None return _play_at(bot, sender, next_pos) @plugin.command("dj") def cmd_dj(bot, trigger): url = (trigger.group(2) or "").strip() if not url: bot.notice("usage: !dj ", trigger.nick) return vid = _extract_video_id(url) if not vid: bot.notice("invalid YouTube URL (shorts, embeds, and live links are not accepted)", trigger.nick) return normalized = _normalize(vid) cap = jv.cfg_val(bot, "dj_queue_cap") with bot.memory["dj_lock"]: queue = bot.memory["dj_queue"] if normalized in queue: return if len(queue) >= cap: bot.notice(f"queue is full ({cap} tracks max)", trigger.nick) return queue.append(normalized) pos = len(queue) bot.say(f"queued at position {pos}", trigger.sender) @plugin.command("np") def cmd_np(bot, trigger): with bot.memory["dj_lock"]: queue = bot.memory["dj_queue"] pos = bot.memory["dj_pos"] if bot.memory["dj_playing"] and 0 <= pos < len(queue): bot.say(f"now playing: {queue[pos]}", trigger.sender) else: bot.say("nothing playing", trigger.sender) @plugin.command("queue", "q") def cmd_queue(bot, trigger): with bot.memory["dj_lock"]: queue = list(bot.memory["dj_queue"]) pos = bot.memory["dj_pos"] loop = bot.memory["dj_loop"] if not queue: bot.say("queue is empty", trigger.sender) return start = pos + 1 if pos >= 0 else 0 if loop: n = len(queue) upcoming = [] for i in range(min(4, n - (1 if pos >= 0 else 0))): idx = (start + i) % n if idx == pos: break upcoming.append(queue[idx]) label = f"{n} in queue (looping)" else: upcoming = queue[start:start + 4] remaining = max(0, len(queue) - start) label = f"{remaining} remaining" if not upcoming: bot.say("no upcoming tracks in queue", trigger.sender) return bot.say(f"up next ({label}):", trigger.sender) for i, song in enumerate(upcoming, 1): bot.say(f"{i}. {song}", trigger.sender) if not loop and remaining > 4: bot.say(f"... and {remaining - 4} more", trigger.sender) @plugin.command("play") def cmd_play(bot, trigger): if not jv.is_authorized(bot, trigger): return with bot.memory["dj_lock"]: if bot.memory["dj_playing"]: bot.say("already playing", trigger.sender) return queue = bot.memory["dj_queue"] if not queue: bot.say("queue is empty", trigger.sender) return pos = bot.memory["dj_pos"] start = pos if 0 <= pos < len(queue) else 0 bot.memory["dj_playing"] = True _play_at(bot, trigger.sender, start) @plugin.command("stop") def cmd_stop(bot, trigger): if not jv.is_authorized(bot, trigger): return with bot.memory["dj_lock"]: if not bot.memory["dj_playing"]: bot.say("already stopped", trigger.sender) return bot.memory["dj_playing"] = False _cancel_timer(bot) bot.say("playback stopped", trigger.sender) @plugin.command("skip") def cmd_skip(bot, trigger): if not jv.is_authorized(bot, trigger): return with bot.memory["dj_lock"]: if not bot.memory["dj_playing"]: bot.say("nothing playing", trigger.sender) return _cancel_timer(bot) _on_track_end(bot, trigger.sender) @plugin.command("remove") def cmd_remove(bot, trigger): if not jv.is_authorized(bot, trigger): return with bot.memory["dj_lock"]: queue = bot.memory["dj_queue"] pos = bot.memory["dj_pos"] if not queue or pos < 0 or pos >= len(queue): bot.say("nothing to remove", trigger.sender) return removed = queue.pop(pos) bot.say(f"removed: {removed}", trigger.sender) if bot.memory["dj_playing"]: bot.memory["dj_pos"] = pos - 1 _cancel_timer(bot) _on_track_end(bot, bot.memory.get("dj_channel") or str(trigger.sender)) else: bot.memory["dj_pos"] = max(-1, pos - 1) @plugin.command("loop") def cmd_loop(bot, trigger): arg = (trigger.group(2) or "").strip().lower() if arg in ("on", "off"): if not jv.is_authorized(bot, trigger): return bot.memory["dj_loop"] = (arg == "on") bot.say(f"loop {arg}", trigger.sender) else: state = "on" if bot.memory.get("dj_loop") else "off" bot.say(f"loop is {state} — use !loop to change (op)", trigger.sender) @plugin.command("clear") def cmd_clear(bot, trigger): if not jv.is_authorized(bot, trigger): return with bot.memory["dj_lock"]: bot.memory["dj_playing"] = False _cancel_timer(bot) bot.memory["dj_queue"].clear() bot.memory["dj_pos"] = -1 bot.memory["dj_loop"] = False bot.memory["dj_channel"] = None bot.say("queue cleared, playback stopped", trigger.sender)