diff --git a/archive_bot/discord_api.py b/archive_bot/discord_api.py index 1ad358a..dc22753 100644 --- a/archive_bot/discord_api.py +++ b/archive_bot/discord_api.py @@ -205,6 +205,49 @@ class DiscordGatewayManager: 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) @@ -317,20 +360,43 @@ class DiscordGatewayManager: await interaction.response.defer(ephemeral=True) await action() - async def do_create(interaction: Any, title: str | None = None) -> None: + 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(voice_channel.id), + voice_channel_id=str(target_vc.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(), + 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) @@ -390,12 +456,24 @@ class DiscordGatewayManager: async def do_end_session(interaction: Any) -> None: session = ensure_session(interaction, create_if_missing=False) - end_watch_party_session(int(session["id"])) + 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) + 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( @@ -412,6 +490,35 @@ class DiscordGatewayManager: 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) @@ -419,7 +526,10 @@ class DiscordGatewayManager: @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 defer_then(interaction, lambda: do_create(interaction, None)) + 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)) @@ -486,10 +596,10 @@ class DiscordGatewayManager: 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: + @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) + await do_create(interaction, title, temp_vc) except Exception as exc: await respond(interaction, str(exc)) diff --git a/archive_bot/watchparty.py b/archive_bot/watchparty.py index 1f13990..a485553 100644 --- a/archive_bot/watchparty.py +++ b/archive_bot/watchparty.py @@ -32,6 +32,7 @@ class WatchPartySession: status: str worker_session_id: str current_queue_entry_id: int | None + is_temp_channel: bool created_at: str updated_at: str @@ -51,11 +52,18 @@ def initialize_watchparty_schema() -> None: status TEXT NOT NULL, worker_session_id TEXT NOT NULL DEFAULT '', current_queue_entry_id INTEGER, + is_temp_channel INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) + import sqlite3 + try: + connection.execute("ALTER TABLE watch_party_sessions ADD COLUMN is_temp_channel INTEGER NOT NULL DEFAULT 0") + connection.commit() + except sqlite3.OperationalError: + pass connection.execute( """ CREATE TABLE IF NOT EXISTS watch_party_queue ( @@ -134,6 +142,7 @@ def watch_party_session_from_row(row: Any) -> WatchPartySession: status=str(row["status"]), worker_session_id=str(row["worker_session_id"] or ""), current_queue_entry_id=int(row["current_queue_entry_id"]) if row["current_queue_entry_id"] is not None else None, + is_temp_channel=bool(row["is_temp_channel"]) if "is_temp_channel" in row.keys() else False, created_at=str(row["created_at"]), updated_at=str(row["updated_at"]), ) @@ -150,6 +159,7 @@ def session_to_jsonable(session: WatchPartySession) -> dict[str, Any]: "status": session.status, "workerSessionId": session.worker_session_id, "currentQueueEntryId": session.current_queue_entry_id, + "isTempChannel": session.is_temp_channel, "createdAt": session.created_at, "updatedAt": session.updated_at, } @@ -234,6 +244,7 @@ def create_watch_party_session( text_channel_id: str, owner_user_id: str, title: str, + is_temp_channel: bool = False, ) -> dict[str, Any]: del runtime initialize_watchparty_schema() @@ -251,11 +262,11 @@ def create_watch_party_session( """ INSERT INTO watch_party_sessions( guild_id, voice_channel_id, text_channel_id, owner_user_id, - title, status, worker_session_id, current_queue_entry_id, created_at, updated_at + title, status, worker_session_id, current_queue_entry_id, is_temp_channel, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, 'draft', '', NULL, ?, ?) + VALUES (?, ?, ?, ?, ?, 'draft', '', NULL, ?, ?, ?) """, - (guild_id.strip(), voice_channel_id.strip(), text_channel_id.strip(), owner_user_id.strip(), title.strip(), now, now), + (guild_id.strip(), voice_channel_id.strip(), text_channel_id.strip(), owner_user_id.strip(), title.strip(), 1 if is_temp_channel else 0, now, now), ) session_id = int(cursor.lastrowid) connection.execute( diff --git a/orb_stream_worker/server.js b/orb_stream_worker/server.js index 5850ea2..8b1c286 100644 --- a/orb_stream_worker/server.js +++ b/orb_stream_worker/server.js @@ -511,6 +511,7 @@ const server = http.createServer(async (request, response) => { streamingError: streamingStack.available ? "" : streamingStack.error, discordConfigured: Boolean(DISCORD_USER_TOKEN), discordReady: runtime.ready, + discordUserId: runtime.client && runtime.client.user ? runtime.client.user.id : null, sessions: sessions.size, activeSessionId: runtime.activeSessionId, });