aboutsummaryrefslogtreecommitdiffstats
path: root/jeeves/dj.py
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /jeeves/dj.py
init: mostly vibed
Diffstat (limited to 'jeeves/dj.py')
-rw-r--r--jeeves/dj.py275
1 files changed, 275 insertions, 0 deletions
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 <url> - 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 <on|off> - 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 <youtube url>", 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 <on|off> 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)