Implement naming modal for watch parties and temporary voice channel support with auto-cleanup

This commit is contained in:
MiTHRAL 2026-05-26 18:29:36 -04:00
parent fcb03ad8c2
commit f95c9c81b6
3 changed files with 133 additions and 11 deletions

View file

@ -205,6 +205,49 @@ class DiscordGatewayManager:
async def on_disconnect(self) -> None: async def on_disconnect(self) -> None:
self.manager.ready.clear() 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() self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop) asyncio.set_event_loop(self.loop)
self.client = GatewayClient(self) self.client = GatewayClient(self)
@ -317,20 +360,43 @@ class DiscordGatewayManager:
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
await action() 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) voice_channel = ensure_voice_context(interaction)
session = session_for_voice(interaction) session = session_for_voice(interaction)
if session is None or session.get("status") in {"stopped", "error"}: 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( payload = create_watch_party_session(
runtime, runtime,
guild_id=str(interaction.guild.id), 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), text_channel_id=str(interaction.channel_id),
owner_user_id=str(interaction.user.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"] session = payload["session"]
message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>." message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>."
if is_temp_channel:
message += " Created a temporary voice channel and moved members."
else: else:
message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>." message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>."
await respond(interaction, message) await respond(interaction, message)
@ -390,12 +456,24 @@ class DiscordGatewayManager:
async def do_end_session(interaction: Any) -> None: async def do_end_session(interaction: Any) -> None:
session = ensure_session(interaction, create_if_missing=False) 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( await respond(
interaction, interaction,
f"Ended the watch-party session `{session['title']}`.", 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"): class WatchPartyAddModal(discord.ui.Modal, title="Add To Watch Party"):
query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120) query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120)
media_type = discord.ui.TextInput( media_type = discord.ui.TextInput(
@ -412,6 +490,35 @@ class DiscordGatewayManager:
except Exception as exc: except Exception as exc:
await respond(interaction, str(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): class WatchPartyPanelView(discord.ui.View):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(timeout=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) @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: async def create_button(self, interaction: Any, _button: Any) -> None:
try: 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: except Exception as exc:
await respond(interaction, str(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 = 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") @group.command(name="create", description="Create a watch-party session for your current voice channel")
@discord.app_commands.describe(title="Optional session title") @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) -> None: async def create_command(interaction: Any, title: str | None = None, temp_vc: bool = False) -> None:
try: try:
await do_create(interaction, title) await do_create(interaction, title, temp_vc)
except Exception as exc: except Exception as exc:
await respond(interaction, str(exc)) await respond(interaction, str(exc))

View file

@ -32,6 +32,7 @@ class WatchPartySession:
status: str status: str
worker_session_id: str worker_session_id: str
current_queue_entry_id: int | None current_queue_entry_id: int | None
is_temp_channel: bool
created_at: str created_at: str
updated_at: str updated_at: str
@ -51,11 +52,18 @@ def initialize_watchparty_schema() -> None:
status TEXT NOT NULL, status TEXT NOT NULL,
worker_session_id TEXT NOT NULL DEFAULT '', worker_session_id TEXT NOT NULL DEFAULT '',
current_queue_entry_id INTEGER, current_queue_entry_id INTEGER,
is_temp_channel INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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( connection.execute(
""" """
CREATE TABLE IF NOT EXISTS watch_party_queue ( 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"]), status=str(row["status"]),
worker_session_id=str(row["worker_session_id"] or ""), 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, 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"]), created_at=str(row["created_at"]),
updated_at=str(row["updated_at"]), updated_at=str(row["updated_at"]),
) )
@ -150,6 +159,7 @@ def session_to_jsonable(session: WatchPartySession) -> dict[str, Any]:
"status": session.status, "status": session.status,
"workerSessionId": session.worker_session_id, "workerSessionId": session.worker_session_id,
"currentQueueEntryId": session.current_queue_entry_id, "currentQueueEntryId": session.current_queue_entry_id,
"isTempChannel": session.is_temp_channel,
"createdAt": session.created_at, "createdAt": session.created_at,
"updatedAt": session.updated_at, "updatedAt": session.updated_at,
} }
@ -234,6 +244,7 @@ def create_watch_party_session(
text_channel_id: str, text_channel_id: str,
owner_user_id: str, owner_user_id: str,
title: str, title: str,
is_temp_channel: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
del runtime del runtime
initialize_watchparty_schema() initialize_watchparty_schema()
@ -251,11 +262,11 @@ def create_watch_party_session(
""" """
INSERT INTO watch_party_sessions( INSERT INTO watch_party_sessions(
guild_id, voice_channel_id, text_channel_id, owner_user_id, 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) session_id = int(cursor.lastrowid)
connection.execute( connection.execute(

View file

@ -511,6 +511,7 @@ const server = http.createServer(async (request, response) => {
streamingError: streamingStack.available ? "" : streamingStack.error, streamingError: streamingStack.available ? "" : streamingStack.error,
discordConfigured: Boolean(DISCORD_USER_TOKEN), discordConfigured: Boolean(DISCORD_USER_TOKEN),
discordReady: runtime.ready, discordReady: runtime.ready,
discordUserId: runtime.client && runtime.client.user ? runtime.client.user.id : null,
sessions: sessions.size, sessions: sessions.size,
activeSessionId: runtime.activeSessionId, activeSessionId: runtime.activeSessionId,
}); });