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 .watchparty import ( add_watch_party_queue_item, control_watch_party_worker, create_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"} 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')}`", ] 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) 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() 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 manager = self 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: 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] 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") async def create_command(interaction: Any, title: str | None = None) -> None: try: voice_channel = ensure_voice_context(interaction) session = session_for_voice(interaction) if session is None: 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 or f"{voice_channel.name} Watch Party").strip(), ) session = payload["session"] message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>." else: message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>." await interaction.response.send_message(message, ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @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 interaction.response.send_message(format_media_search_results(items), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @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: 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"])) queue = payload["queue"] await interaction.response.send_message( f"Added `{item['title']}` to `{payload['session']['title']}`. Queue size: {len(queue)}.", ephemeral=True, ) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @group.command(name="start", description="Connect the worker and start playback in your current voice channel") async def start_command(interaction: Any) -> None: try: 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 interaction.response.send_message( f"Started `{payload['session']['title']}` in <#{payload['session']['voiceChannelId']}>.\n" f"Now playing `{payload['worker'].get('currentTitle') or payload['session']['title']}`.", ephemeral=True, ) except ValueError as exc: if "No queued items available" not in str(exc): raise await interaction.response.send_message( f"Connected the worker for `{worker_payload['session']['title']}`, but the queue is empty.", ephemeral=True, ) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @group.command(name="pause", description="Pause the current watch-party stream") async def pause_command(interaction: Any) -> None: try: session = ensure_session(interaction, create_if_missing=False) payload = control_watch_party_worker(int(session["id"]), "pause", {}) await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @group.command(name="resume", description="Resume the current watch-party stream") async def resume_command(interaction: Any) -> None: try: session = ensure_session(interaction, create_if_missing=False) payload = control_watch_party_worker(int(session["id"]), "resume", {}) await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @group.command(name="stop", description="Stop the current watch-party stream") async def stop_command(interaction: Any) -> None: try: session = ensure_session(interaction, create_if_missing=False) payload = control_watch_party_worker(int(session["id"]), "stop", {}) await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @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: session = ensure_session(interaction, create_if_missing=False) payload = control_watch_party_worker(int(session["id"]), "seek", {"positionSeconds": max(0, position_seconds)}) await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @group.command(name="queue", description="Show the queue for your current voice channel watch party") async def queue_command(interaction: Any) -> None: try: session = ensure_session(interaction, create_if_missing=False) payload = get_watch_party_session(int(session["id"])) await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) @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: 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 interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True) except Exception as exc: await interaction.response.send_message(str(exc), ephemeral=True) tree.add_command(group) 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")