from __future__ import annotations import asyncio import json import secrets import sys import threading import urllib.error import urllib.request from typing import Any from .core import BotRuntime, DISCORD_API from .storage import channel_settings, load_state, save_state from .watchparty import ( add_watch_party_queue_item, clear_watch_party_queue, control_watch_party_worker, create_watch_party_session, end_watch_party_session, find_watch_party_session, get_watch_party_session, list_media_candidates, play_next_watch_party_item, refresh_watch_party_worker, start_watch_party_worker, ) from .worker_client import worker_enabled WATCH_PARTY_ACTIVE_STATUSES = {"draft", "queued", "connecting", "playing", "paused"} WATCH_PARTY_PANEL_MESSAGE_KEY = "watch_party_panel_message_id" WATCH_PARTY_PANEL_CREATE_ID = "watchparty_panel:create" WATCH_PARTY_PANEL_ADD_ID = "watchparty_panel:add" WATCH_PARTY_PANEL_START_ID = "watchparty_panel:start" WATCH_PARTY_PANEL_PAUSE_ID = "watchparty_panel:pause" WATCH_PARTY_PANEL_RESUME_ID = "watchparty_panel:resume" WATCH_PARTY_PANEL_STOP_ID = "watchparty_panel:stop" WATCH_PARTY_PANEL_QUEUE_ID = "watchparty_panel:queue" WATCH_PARTY_PANEL_STATUS_ID = "watchparty_panel:status" WATCH_PARTY_PANEL_CLEAR_QUEUE_ID = "watchparty_panel:clear_queue" WATCH_PARTY_PANEL_END_SESSION_ID = "watchparty_panel:end_session" def format_watch_party_summary(payload: dict[str, Any]) -> str: session = payload.get("session", {}) worker = payload.get("worker") or {} queue = payload.get("queue") or [] lines = [ f"Session `{session.get('title', 'Watch Party')}`", f"Status: `{session.get('status', 'unknown')}`", f"Worker: `{worker.get('workerStatus', 'idle')}` · Playback: `{worker.get('playbackState', 'idle')}`", ] last_error = str(worker.get("lastError", "") or "").strip() if last_error: lines.append(f"Error: `{last_error}`") current_title = str(worker.get("currentTitle", "") or "").strip() if current_title: lines.append(f"Now playing: `{current_title}`") if queue: preview = [] for entry in queue[:5]: preview.append(f"{entry.get('position', '?')}. {entry.get('title', 'Untitled')} [{entry.get('status', 'queued')}]") lines.append("Queue:\n" + "\n".join(preview)) if len(queue) > 5: lines.append(f"...and {len(queue) - 5} more") else: lines.append("Queue: empty") return "\n".join(lines) def format_media_search_results(items: list[dict[str, Any]]) -> str: if not items: return "No matches found." lines: list[str] = [] for item in items[:8]: details = [str(item.get("mediaType", "")), str(item.get("year", "")), str(item.get("genres", ""))] meta = " · ".join(part for part in details if part) lines.append(f"- `{item.get('title', 'Untitled')}`{f' — {meta}' if meta else ''}") return "\n".join(lines) def watch_party_panel_payload() -> dict[str, Any]: return { "embeds": [ { "title": "Watch Party Controls", "description": ( "Join a voice channel, then use the buttons below.\n\n" "`Create` makes a session for your current VC.\n" "`Add` opens a prompt for a Jellyfin title.\n" "`Start` connects the worker and plays the next queued item." ), "color": 0x8B5CF6, } ], "components": [ { "type": 1, "components": [ {"type": 2, "style": 1, "label": "Create", "custom_id": WATCH_PARTY_PANEL_CREATE_ID}, {"type": 2, "style": 1, "label": "Add", "custom_id": WATCH_PARTY_PANEL_ADD_ID}, {"type": 2, "style": 3, "label": "Start", "custom_id": WATCH_PARTY_PANEL_START_ID}, {"type": 2, "style": 2, "label": "Queue", "custom_id": WATCH_PARTY_PANEL_QUEUE_ID}, {"type": 2, "style": 2, "label": "Status", "custom_id": WATCH_PARTY_PANEL_STATUS_ID}, ], }, { "type": 1, "components": [ {"type": 2, "style": 2, "label": "Pause", "custom_id": WATCH_PARTY_PANEL_PAUSE_ID}, {"type": 2, "style": 2, "label": "Resume", "custom_id": WATCH_PARTY_PANEL_RESUME_ID}, {"type": 2, "style": 2, "label": "Stop", "custom_id": WATCH_PARTY_PANEL_STOP_ID}, {"type": 2, "style": 2, "label": "Clear Queue", "custom_id": WATCH_PARTY_PANEL_CLEAR_QUEUE_ID}, {"type": 2, "style": 4, "label": "End Session", "custom_id": WATCH_PARTY_PANEL_END_SESSION_ID}, ], }, ], } def publish_watch_party_panel(runtime: BotRuntime, token: str) -> dict[str, Any]: settings = channel_settings(runtime) channel_id = str(settings.get("watchPartyChannelId", "")).strip() if not channel_id: raise ValueError("Watch-party channel ID is not configured") state = load_state(runtime.settings_path) payload = watch_party_panel_payload() message_id = str(state.get(WATCH_PARTY_PANEL_MESSAGE_KEY, "")).strip() if message_id: try: message = discord_request("PATCH", token, f"/channels/{channel_id}/messages/{message_id}", payload) except RuntimeError as exc: if "failed: 404" not in str(exc): raise message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload) else: message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload) state[WATCH_PARTY_PANEL_MESSAGE_KEY] = str(message.get("id", "")).strip() save_state(runtime.settings_path, state) return { "channelId": channel_id, "messageId": state[WATCH_PARTY_PANEL_MESSAGE_KEY], } class DiscordGatewayManager: def __init__(self, token: str, runtime: BotRuntime) -> None: self.token = token self.runtime = runtime self.thread: threading.Thread | None = None self.loop: asyncio.AbstractEventLoop | None = None self.client: Any = None self.ready = threading.Event() self._disconnecting = threading.Event() self._commands_registered = False self._commands_synced = False def start(self) -> None: if self.thread is not None: return self.thread = threading.Thread(target=self._run, name="discord-gateway", daemon=True) self.thread.start() def stop(self) -> None: self._disconnecting.set() if self.loop is not None and self.client is not None: asyncio.run_coroutine_threadsafe(self.client.close(), self.loop) if self.thread is not None: self.thread.join(timeout=15) def _run(self) -> None: try: import discord except ImportError: print( "discord.py is not installed. Install requirements.txt to keep the bot online.", file=sys.stderr, flush=True, ) self.ready.set() return class GatewayClient(discord.Client): def __init__(self, manager: DiscordGatewayManager) -> None: intents = discord.Intents.default() intents.guilds = True intents.voice_states = True super().__init__(intents=intents) self.manager = manager self.tree = discord.app_commands.CommandTree(self) async def setup_hook(self) -> None: self.manager.register_watchparty_commands(self.tree, discord) async def on_ready(self) -> None: await self.change_presence(status=discord.Status.online) user = self.user name = user.name if user is not None else "unknown" bot_id = user.id if user is not None else "unknown" await self.manager.sync_commands(self.tree, discord) print(f"Discord gateway connected as {name} ({bot_id})", flush=True) self.manager.ready.set() async def on_disconnect(self) -> None: self.manager.ready.clear() async def on_voice_state_update(self, member: Any, before: Any, after: Any) -> None: if before.channel is not None and before.channel != after.channel: try: from .db import connect_db from .watchparty import end_watch_party_session selfbot_user_id = None from .worker_client import worker_enabled, worker_health if worker_enabled(): try: health = worker_health() selfbot_user_id = str(health.get("discordUserId") or "") except Exception: pass with connect_db() as connection: row = connection.execute( "SELECT id, is_temp_channel FROM watch_party_sessions WHERE voice_channel_id = ? AND status != 'stopped'", (str(before.channel.id),), ).fetchone() if row is not None: session_id = int(row["id"]) is_temp = bool(row["is_temp_channel"]) humans = [ m for m in before.channel.members if not m.bot and str(m.id) != selfbot_user_id ] if len(humans) == 0: print(f"[on_voice_state_update] Voice channel {before.channel.name} ({before.channel.id}) is empty of humans. Ending session {session_id}.", flush=True) await asyncio.to_thread(end_watch_party_session, session_id) if is_temp: print(f"[on_voice_state_update] Deleting temporary voice channel {before.channel.name} ({before.channel.id}).", flush=True) try: await before.channel.delete() except Exception as exc: print(f"Failed to delete temporary channel {before.channel.id}: {exc}", flush=True) except Exception as err: print(f"Error in on_voice_state_update handling: {err}", flush=True) self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.client = GatewayClient(self) try: self.loop.run_until_complete(self.client.start(self.token)) except Exception as exc: if not self._disconnecting.is_set(): print(f"Discord gateway stopped: {exc}", file=sys.stderr, flush=True) finally: self.ready.set() try: pending = asyncio.all_tasks(self.loop) for task in pending: task.cancel() if pending: self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) finally: self.loop.close() async def sync_commands(self, tree: Any, discord: Any) -> None: if self._commands_synced: return try: await tree.sync() for guild in self.client.guilds if self.client is not None else []: tree.copy_global_to(guild=guild) await tree.sync(guild=guild) except Exception as exc: print(f"Discord command sync failed: {exc}", file=sys.stderr, flush=True) else: self._commands_synced = True def register_watchparty_commands(self, tree: Any, discord: Any) -> None: if self._commands_registered: return runtime = self.runtime def current_voice_channel(interaction: Any) -> Any | None: user = interaction.user voice = getattr(user, "voice", None) return getattr(voice, "channel", None) def ensure_guild_context(interaction: Any) -> None: if interaction.guild is None: raise ValueError("This command only works inside a server") def ensure_voice_context(interaction: Any) -> Any: ensure_guild_context(interaction) voice_channel = current_voice_channel(interaction) if voice_channel is None: raise ValueError("Join a voice channel first") return voice_channel def session_for_voice(interaction: Any) -> dict[str, Any] | None: voice_channel = current_voice_channel(interaction) if interaction.guild is None or voice_channel is None: return None session = find_watch_party_session( guild_id=str(interaction.guild.id), voice_channel_id=str(voice_channel.id), allowed_statuses=WATCH_PARTY_ACTIVE_STATUSES, ) if session is not None: return session return find_watch_party_session( guild_id=str(interaction.guild.id), voice_channel_id=str(voice_channel.id), allowed_statuses=None, ) def ensure_session(interaction: Any, *, create_if_missing: bool = False, title: str = "Watch Party") -> dict[str, Any]: ensure_guild_context(interaction) voice_channel = ensure_voice_context(interaction) session = session_for_voice(interaction) if session is not None: if not create_if_missing or session.get("status") not in {"stopped", "error"}: return session if not create_if_missing: raise ValueError("No watch-party session exists for your voice channel. Run /watchparty create first.") payload = create_watch_party_session( runtime, guild_id=str(interaction.guild.id), voice_channel_id=str(voice_channel.id), text_channel_id=str(interaction.channel_id), owner_user_id=str(interaction.user.id), title=title, ) return payload["session"] def pick_media_item(query: str, media_type: str) -> dict[str, Any]: items = list_media_candidates(runtime, media_type=media_type, search=query, limit=8) if not items: raise ValueError("No library matches found for that query") exact_matches = [item for item in items if str(item.get("title", "")).casefold() == query.strip().casefold()] if len(items) > 1 and not exact_matches: preview = format_media_search_results(items[:5]) raise ValueError(f"Query matched multiple titles. Refine it:\n{preview}") return exact_matches[0] if exact_matches else items[0] async def respond(interaction: Any, message: str) -> None: if interaction.response.is_done(): await interaction.followup.send(message, ephemeral=True) else: await interaction.response.send_message(message, ephemeral=True) async def defer_then(interaction: Any, action) -> None: if not interaction.response.is_done(): await interaction.response.defer(ephemeral=True) await action() async def do_create(interaction: Any, title: str | None = None, is_temp_channel: bool = False) -> None: voice_channel = ensure_voice_context(interaction) session = session_for_voice(interaction) if session is None or session.get("status") in {"stopped", "error"}: title_str = (title or f"{voice_channel.name} Watch Party").strip() target_vc = voice_channel if is_temp_channel: try: target_vc = await interaction.guild.create_voice_channel( name=title_str, category=voice_channel.category ) members_to_move = list(voice_channel.members) for m in members_to_move: try: await m.move_to(target_vc) except Exception as exc: print(f"Failed to move member {m.name}: {exc}", flush=True) except Exception as exc: print(f"Failed to create temporary voice channel: {exc}", flush=True) target_vc = voice_channel is_temp_channel = False payload = create_watch_party_session( runtime, guild_id=str(interaction.guild.id), voice_channel_id=str(target_vc.id), text_channel_id=str(interaction.channel_id), owner_user_id=str(interaction.user.id), title=title_str, is_temp_channel=is_temp_channel, ) session = payload["session"] message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>." if is_temp_channel: message += " Created a temporary voice channel and moved members." else: message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>." await respond(interaction, message) async def do_add(interaction: Any, query: str, media_type: str = "all") -> None: session = ensure_session(interaction, create_if_missing=True, title="Watch Party") choice = media_type.strip().lower() if media_type.strip().lower() in {"all", "movie", "show"} else "all" item = pick_media_item(query, choice) payload = add_watch_party_queue_item(runtime, int(session["id"]), str(item["sourceId"])) await respond( interaction, f"Added `{item['title']}` to `{payload['session']['title']}`. Queue size: {len(payload['queue'])}.", ) async def do_start(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) if not worker_enabled(): raise ValueError("Watch-party worker is not configured") worker_payload = start_watch_party_worker(int(session["id"])) try: payload = play_next_watch_party_item(runtime, int(session["id"])) await respond( interaction, f"Started `{payload['session']['title']}` in <#{payload['session']['voiceChannelId']}>.\n" f"Now playing `{payload['worker'].get('currentTitle') or payload['session']['title']}`.", ) except ValueError as exc: if "No queued items available" not in str(exc): raise await respond( interaction, f"Connected the worker for `{worker_payload['session']['title']}`, but the queue is empty.", ) async def do_control(interaction: Any, action: str, data: dict[str, Any] | None = None) -> None: session = ensure_session(interaction, create_if_missing=False) payload = control_watch_party_worker(int(session["id"]), action, data or {}) await respond(interaction, format_watch_party_summary(payload)) async def do_queue(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) payload = get_watch_party_session(int(session["id"])) await respond(interaction, format_watch_party_summary(payload)) async def do_status(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) payload = refresh_watch_party_worker(int(session["id"])) if worker_enabled() else get_watch_party_session(int(session["id"])) await respond(interaction, format_watch_party_summary(payload)) async def do_clear_queue(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) payload = clear_watch_party_queue(int(session["id"])) await respond( interaction, f"Cleared all queued items from `{payload['session']['title']}`. Queue size: {len(payload['queue'])}.", ) async def do_end_session(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) session_id = int(session["id"]) is_temp = session.get("isTempChannel", False) vc_id = session.get("voiceChannelId") await asyncio.to_thread(end_watch_party_session, session_id) await respond( interaction, f"Ended the watch-party session `{session['title']}`.", ) if is_temp and vc_id: try: channel = interaction.guild.get_channel(int(vc_id)) if channel: await channel.delete() except Exception as exc: print(f"Failed to delete temporary channel {vc_id}: {exc}", flush=True) async def do_subs(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) with connect_db() as connection: row = connection.execute( """ SELECT q.jellyfin_source_id, q.title FROM watch_party_sessions s JOIN watch_party_queue q ON s.current_queue_entry_id = q.id WHERE s.id = ? """, (int(session["id"]),) ).fetchone() if row is None: raise ValueError("No item is currently playing") source_id = str(row["jellyfin_source_id"]) title = str(row["title"]) settings = jellyfin_connection_settings(runtime) data = await asyncio.to_thread(jellyfin_request, settings, f"/Items/{source_id}", {}) media_sources = data.get("MediaSources", []) if not media_sources: raise ValueError(f"No media sources found for {title}") sub_streams = [] for source in media_sources: for stream in source.get("MediaStreams", []): if stream.get("Type") == "Subtitle": sub_streams.append(stream) if not sub_streams: await respond(interaction, f"No subtitle tracks found for `{title}`.") return options = [ discord.SelectOption( label="Subtitles Off", value="off", description="Disable subtitles", default=(session.get("subtitleStreamIndex") is None) ) ] for stream in sub_streams: idx = stream.get("Index") lang = stream.get("Language") or "und" display = stream.get("DisplayTitle") or f"Track {idx}" options.append( discord.SelectOption( label=display[:80], value=str(idx), description=f"Language: {lang}", default=(session.get("subtitleStreamIndex") == idx) ) ) class SubtitleSelect(discord.ui.Select): def __init__(self) -> None: super().__init__( placeholder="Choose a subtitle track...", min_values=1, max_values=1, options=options[:25] ) async def callback(self, inter: Any) -> None: val = self.values[0] sub_idx = None if val == "off" else int(val) from .watchparty import change_watch_party_subtitles await inter.response.defer(ephemeral=True) try: await asyncio.to_thread(change_watch_party_subtitles, runtime, int(session["id"]), sub_idx) label = "Disabled subtitles" if sub_idx is None else f"Switched subtitles to track {sub_idx}" await inter.followup.send(f"{label} and restarted playback.", ephemeral=True) except Exception as exc: await inter.followup.send(f"Updated subtitles, but failed to restart stream: {exc}", ephemeral=True) class SubtitleView(discord.ui.View): def __init__(self) -> None: super().__init__(timeout=60) self.add_item(SubtitleSelect()) if interaction.response.is_done(): await interaction.followup.send("Select a subtitle track:", view=SubtitleView(), ephemeral=True) else: await interaction.response.send_message("Select a subtitle track:", view=SubtitleView(), ephemeral=True) class WatchPartyAddModal(discord.ui.Modal, title="Add To Watch Party"): query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120) media_type = discord.ui.TextInput( label="Type", placeholder="all, movie, or show", required=False, default="all", max_length=16, ) async def on_submit(self, interaction: Any) -> None: try: await defer_then(interaction, lambda: do_add(interaction, str(self.query.value), str(self.media_type.value or "all"))) except Exception as exc: await respond(interaction, str(exc)) class WatchPartyCreateModal(discord.ui.Modal, title="Create Watch Party"): def __init__(self, default_title: str) -> None: super().__init__() self.party_title = discord.ui.TextInput( label="Watch Party Name", placeholder="e.g. Movie Night", default=default_title, required=True, max_length=100 ) self.add_item(self.party_title) self.temp_vc_opt = discord.ui.TextInput( label="Create temporary Voice Channel?", placeholder="yes or no (default: yes)", default="yes", required=True, max_length=5 ) self.add_item(self.temp_vc_opt) async def on_submit(self, interaction: Any) -> None: try: title_val = str(self.party_title.value).strip() is_temp = str(self.temp_vc_opt.value).strip().lower() in {"yes", "y", "true", "1"} await defer_then(interaction, lambda: do_create(interaction, title_val, is_temp)) except Exception as exc: await respond(interaction, str(exc)) class WatchPartyPanelView(discord.ui.View): def __init__(self) -> None: super().__init__(timeout=None) @discord.ui.button(label="Create", style=discord.ButtonStyle.primary, custom_id=WATCH_PARTY_PANEL_CREATE_ID) async def create_button(self, interaction: Any, _button: Any) -> None: try: voice_channel = ensure_voice_context(interaction) await interaction.response.send_modal( WatchPartyCreateModal(default_title=f"{voice_channel.name} Watch Party") ) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Add", style=discord.ButtonStyle.primary, custom_id=WATCH_PARTY_PANEL_ADD_ID) async def add_button(self, interaction: Any, _button: Any) -> None: await interaction.response.send_modal(WatchPartyAddModal()) @discord.ui.button(label="Start", style=discord.ButtonStyle.success, custom_id=WATCH_PARTY_PANEL_START_ID) async def start_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_start(interaction)) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Queue", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_QUEUE_ID) async def queue_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_queue(interaction)) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Status", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_STATUS_ID) async def status_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_status(interaction)) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Pause", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_PAUSE_ID, row=1) async def pause_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_control(interaction, "pause", {})) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Resume", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_RESUME_ID, row=1) async def resume_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_control(interaction, "resume", {})) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Stop", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_STOP_ID, row=1) async def stop_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_control(interaction, "stop", {})) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Clear Queue", style=discord.ButtonStyle.secondary, custom_id=WATCH_PARTY_PANEL_CLEAR_QUEUE_ID, row=1) async def clear_queue_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_clear_queue(interaction)) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="End Session", style=discord.ButtonStyle.danger, custom_id=WATCH_PARTY_PANEL_END_SESSION_ID, row=1) async def end_session_button(self, interaction: Any, _button: Any) -> None: try: await defer_then(interaction, lambda: do_end_session(interaction)) except Exception as exc: await respond(interaction, str(exc)) @discord.ui.button(label="Subtitles", style=discord.ButtonStyle.secondary, custom_id="watchparty_panel:subtitles", row=2) async def subtitles_button(self, interaction: Any, _button: Any) -> None: try: await do_subs(interaction) except Exception as exc: await respond(interaction, str(exc)) group = discord.app_commands.Group(name="watchparty", description="Control Jellyfin watch parties in Discord") @group.command(name="create", description="Create a watch-party session for your current voice channel") @discord.app_commands.describe(title="Optional session title", temp_vc="Create a temporary voice channel and move members") async def create_command(interaction: Any, title: str | None = None, temp_vc: bool = False) -> None: try: await do_create(interaction, title, temp_vc) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="search", description="Search the saved Jellyfin library for watch-party titles") @discord.app_commands.describe(query="Movie or show title", media_type="Limit the search to movies or shows") async def search_command(interaction: Any, query: str, media_type: str = "all") -> None: try: ensure_guild_context(interaction) choice = media_type.strip().lower() if media_type.strip().lower() in {"all", "movie", "show"} else "all" items = list_media_candidates(runtime, media_type=choice, search=query, limit=8) await respond(interaction, format_media_search_results(items)) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="add", description="Add a Jellyfin title to the current voice channel watch-party queue") @discord.app_commands.describe(query="Exact or refined movie/show title", media_type="Limit the search to movies or shows") async def add_command(interaction: Any, query: str, media_type: str = "all") -> None: try: await do_add(interaction, query, media_type) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="start", description="Connect the worker and start playback in your current voice channel") async def start_command(interaction: Any) -> None: try: await do_start(interaction) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="pause", description="Pause the current watch-party stream") async def pause_command(interaction: Any) -> None: try: await do_control(interaction, "pause", {}) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="resume", description="Resume the current watch-party stream") async def resume_command(interaction: Any) -> None: try: await do_control(interaction, "resume", {}) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="stop", description="Stop the current watch-party stream") async def stop_command(interaction: Any) -> None: try: await do_control(interaction, "stop", {}) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="seek", description="Seek the current stream to a time offset in seconds") @discord.app_commands.describe(position_seconds="Target playback position in seconds") async def seek_command(interaction: Any, position_seconds: int) -> None: try: await do_control(interaction, "seek", {"positionSeconds": max(0, position_seconds)}) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="queue", description="Show the queue for your current voice channel watch party") async def queue_command(interaction: Any) -> None: try: await do_queue(interaction) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="status", description="Refresh and show the worker status for your current voice channel watch party") async def status_command(interaction: Any) -> None: try: await do_status(interaction) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="clear", description="Clear the watch-party queue") async def clear_command(interaction: Any) -> None: try: await do_clear_queue(interaction) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="end", description="End the current watch-party session") async def end_command(interaction: Any) -> None: try: await do_end_session(interaction) except Exception as exc: await respond(interaction, str(exc)) @group.command(name="subs", description="Select subtitles for the current playing movie/show") async def subs_command(interaction: Any) -> None: try: await do_subs(interaction) except Exception as exc: await respond(interaction, str(exc)) tree.add_command(group) self.client.add_view(WatchPartyPanelView()) self._commands_registered = True def discord_request( method: str, token: str, path: str, payload: dict[str, Any] | None = None, ) -> dict[str, Any]: body = None headers = { "Authorization": f"Bot {token}", "User-Agent": "ArchiveStatusBot/1.0", } if payload is not None: body = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request( f"{DISCORD_API}{path}", data=body, headers=headers, method=method, ) try: with urllib.request.urlopen(request, timeout=20) as response: data = response.read() if not data: return {} return json.loads(data.decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="ignore") raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc def discord_multipart_request( method: str, token: str, path: str, payload: dict[str, Any], files: list[tuple[str, str, str, bytes]], ) -> dict[str, Any]: boundary = f"----ArchiveBot{secrets.token_hex(16)}" body = bytearray() def add_part(name: str, content: bytes, content_type: str, filename: str | None = None) -> None: body.extend(f"--{boundary}\r\n".encode("ascii")) disposition = f'Content-Disposition: form-data; name="{name}"' if filename is not None: disposition += f'; filename="{filename}"' body.extend(f"{disposition}\r\n".encode("utf-8")) body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("ascii")) body.extend(content) body.extend(b"\r\n") add_part("payload_json", json.dumps(payload).encode("utf-8"), "application/json") for field_name, filename, content_type, content in files: add_part(field_name, content, content_type, filename) body.extend(f"--{boundary}--\r\n".encode("ascii")) request = urllib.request.Request( f"{DISCORD_API}{path}", data=bytes(body), headers={ "Authorization": f"Bot {token}", "User-Agent": "ArchiveStatusBot/1.0", "Content-Type": f"multipart/form-data; boundary={boundary}", }, method=method, ) try: with urllib.request.urlopen(request, timeout=30) as response: data = response.read() if not data: return {} return json.loads(data.decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="ignore") raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc def discord_delete_message(token: str, channel_id: str, message_id: str) -> None: try: discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}") except RuntimeError as exc: print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr) def discord_bot_identity(token: str) -> dict[str, Any]: return discord_request("GET", token, "/users/@me")