diff --git a/archive_bot/dashboard.py b/archive_bot/dashboard.py index 8b7094a..960df4e 100644 --- a/archive_bot/dashboard.py +++ b/archive_bot/dashboard.py @@ -29,6 +29,7 @@ from .media import ( update_jellyfin_sync_state, ) from .status import result_to_jsonable, run_check_cycle, runtime_status, save_services_config, services_from_data +from .discord_api import publish_watch_party_panel from .storage import ( channel_settings, jellyfin_settings, @@ -247,6 +248,9 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t if path == "/api/watchparty/worker-status": self.handle_watchparty_worker_status() return + if path == "/api/watchparty/panel/publish": + self.handle_watchparty_panel_publish() + return self.send_error(HTTPStatus.NOT_FOUND) def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None: @@ -522,6 +526,7 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t runtime, str(channels.get("statusChannelId", "")), str(channels.get("mediaChannelId", "")), + str(channels.get("watchPartyChannelId", "")), str(channels.get("catalogUrl", "")), ) except Exception as exc: @@ -701,6 +706,15 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t self.send_json(HTTPStatus.OK, {"worker": worker, **result}) + def handle_watchparty_panel_publish(self) -> None: + try: + result = publish_watch_party_panel(runtime, runtime.token) + except Exception as exc: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + self.send_json(HTTPStatus.OK, result) + return DashboardHandler diff --git a/archive_bot/discord_api.py b/archive_bot/discord_api.py index 63fdc22..14fd827 100644 --- a/archive_bot/discord_api.py +++ b/archive_bot/discord_api.py @@ -10,6 +10,7 @@ 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, control_watch_party_worker, @@ -25,6 +26,15 @@ 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" def format_watch_party_summary(payload: dict[str, Any]) -> str: @@ -62,6 +72,69 @@ def format_media_search_results(items: list[dict[str, Any]]) -> str: 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": 4, "label": "Stop", "custom_id": WATCH_PARTY_PANEL_STOP_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 @@ -160,7 +233,6 @@ class DiscordGatewayManager: if self._commands_registered: return - manager = self runtime = self.runtime def current_voice_channel(interaction: Any) -> Any | None: @@ -224,30 +296,157 @@ class DiscordGatewayManager: 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 do_create(interaction: Any, title: str | None = None) -> None: + 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 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)) + + 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 do_add(interaction, str(self.query.value), str(self.media_type.value or "all")) + 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: + await do_create(interaction, None) + 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 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 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 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 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 do_control(interaction, "resume", {}) + except Exception as exc: + await respond(interaction, str(exc)) + + @discord.ui.button(label="Stop", style=discord.ButtonStyle.danger, custom_id=WATCH_PARTY_PANEL_STOP_ID, row=1) + async def stop_button(self, interaction: Any, _button: Any) -> None: + try: + await do_control(interaction, "stop", {}) + 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") 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) + await do_create(interaction, title) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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") @@ -256,106 +455,70 @@ class DiscordGatewayManager: 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) + await respond(interaction, format_media_search_results(items)) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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: - 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, - ) + await do_add(interaction, query, media_type) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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: - 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, - ) + await do_start(interaction) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + await respond(interaction, str(exc)) @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) + await do_control(interaction, "pause", {}) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + await respond(interaction, str(exc)) @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) + await do_control(interaction, "resume", {}) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + await respond(interaction, str(exc)) @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) + await do_control(interaction, "stop", {}) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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: - 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) + await do_control(interaction, "seek", {"positionSeconds": max(0, position_seconds)}) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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: - 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) + await do_queue(interaction) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + 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: - 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) + await do_status(interaction) except Exception as exc: - await interaction.response.send_message(str(exc), ephemeral=True) + await respond(interaction, str(exc)) tree.add_command(group) + self.client.add_view(WatchPartyPanelView()) self._commands_registered = True diff --git a/archive_bot/media.py b/archive_bot/media.py index 2990f73..d964b4c 100644 --- a/archive_bot/media.py +++ b/archive_bot/media.py @@ -969,6 +969,9 @@ def media_item_card_payload(item: MediaItem, reason: str = "") -> dict[str, Any] return { "title": item.title, "mediaType": item.media_type, + "sourceId": item.source_id or "", + "tmdbId": item.tmdb_id or "", + "imdbId": item.imdb_id or "", "posterUrl": catalog_poster_path(item), "year": item.year or "", "genres": item.genres or "", diff --git a/archive_bot/storage.py b/archive_bot/storage.py index 41cdcec..c32f235 100644 --- a/archive_bot/storage.py +++ b/archive_bot/storage.py @@ -35,10 +35,12 @@ def channel_settings(runtime: BotRuntime) -> dict[str, str]: data = load_state(runtime.settings_path) status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id media_channel = str(data.get("media_channel_id", "")).strip() or status_channel + watch_party_channel = str(data.get("watch_party_channel_id", "")).strip() or media_channel catalog_url = str(data.get("catalog_url", "")).strip() or os.getenv("PUBLIC_CATALOG_URL", "").strip() return { "statusChannelId": status_channel, "mediaChannelId": media_channel, + "watchPartyChannelId": watch_party_channel, "catalogUrl": catalog_url, } @@ -57,15 +59,18 @@ def save_channel_settings( runtime: BotRuntime, status_channel_id: str, media_channel_id: str, + watch_party_channel_id: str = "", catalog_url: str = "", ) -> dict[str, str]: status_channel = validate_channel_id(status_channel_id, "Status") media_channel = validate_channel_id(media_channel_id or status_channel, "Media") + watch_party_channel = validate_channel_id(watch_party_channel_id or media_channel, "Watch Party") state = load_state(runtime.settings_path) state.update( { "status_channel_id": status_channel, "media_channel_id": media_channel, + "watch_party_channel_id": watch_party_channel, "catalog_url": validate_catalog_url(catalog_url), "updated_at": datetime.now(timezone.utc).isoformat(), } diff --git a/archive_bot/watchparty.py b/archive_bot/watchparty.py index d64f433..7f0d2c3 100644 --- a/archive_bot/watchparty.py +++ b/archive_bot/watchparty.py @@ -633,9 +633,19 @@ def control_watch_party_worker(session_id: int, action: str, data: dict[str, Any def refresh_watch_party_worker(session_id: int) -> dict[str, Any]: + session_payload = get_watch_party_session(session_id) + session = session_payload["session"] + if not str(session.get("workerSessionId", "")).strip(): + return session_payload + from .worker_client import worker_status - worker = worker_status(session_id) + try: + worker = worker_status(session_id) + except RuntimeError as exc: + if "HTTP 404" in str(exc): + return session_payload + raise result = update_worker_state( session_id, worker_status=str(worker.get("workerStatus", "idle")), diff --git a/dashboard.html b/dashboard.html index da3bb3b..3fedb61 100644 --- a/dashboard.html +++ b/dashboard.html @@ -803,6 +803,7 @@