From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- jeeves/dj.py | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 jeeves/dj.py (limited to 'jeeves/dj.py') diff --git a/jeeves/dj.py b/jeeves/dj.py new file mode 100644 index 0000000..c270c64 --- /dev/null +++ b/jeeves/dj.py @@ -0,0 +1,275 @@ +""" +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) -- cgit v1.2.3