Fix watch party queue flow and add control panel
This commit is contained in:
parent
e6ca8531ca
commit
5b356c2cf6
6 changed files with 295 additions and 76 deletions
|
|
@ -29,6 +29,7 @@ from .media import (
|
||||||
update_jellyfin_sync_state,
|
update_jellyfin_sync_state,
|
||||||
)
|
)
|
||||||
from .status import result_to_jsonable, run_check_cycle, runtime_status, save_services_config, services_from_data
|
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 (
|
from .storage import (
|
||||||
channel_settings,
|
channel_settings,
|
||||||
jellyfin_settings,
|
jellyfin_settings,
|
||||||
|
|
@ -247,6 +248,9 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
if path == "/api/watchparty/worker-status":
|
if path == "/api/watchparty/worker-status":
|
||||||
self.handle_watchparty_worker_status()
|
self.handle_watchparty_worker_status()
|
||||||
return
|
return
|
||||||
|
if path == "/api/watchparty/panel/publish":
|
||||||
|
self.handle_watchparty_panel_publish()
|
||||||
|
return
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
|
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,
|
runtime,
|
||||||
str(channels.get("statusChannelId", "")),
|
str(channels.get("statusChannelId", "")),
|
||||||
str(channels.get("mediaChannelId", "")),
|
str(channels.get("mediaChannelId", "")),
|
||||||
|
str(channels.get("watchPartyChannelId", "")),
|
||||||
str(channels.get("catalogUrl", "")),
|
str(channels.get("catalogUrl", "")),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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})
|
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
|
return DashboardHandler
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import urllib.request
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .core import BotRuntime, DISCORD_API
|
from .core import BotRuntime, DISCORD_API
|
||||||
|
from .storage import channel_settings, load_state, save_state
|
||||||
from .watchparty import (
|
from .watchparty import (
|
||||||
add_watch_party_queue_item,
|
add_watch_party_queue_item,
|
||||||
control_watch_party_worker,
|
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_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:
|
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)
|
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:
|
class DiscordGatewayManager:
|
||||||
def __init__(self, token: str, runtime: BotRuntime) -> None:
|
def __init__(self, token: str, runtime: BotRuntime) -> None:
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
@ -160,7 +233,6 @@ class DiscordGatewayManager:
|
||||||
if self._commands_registered:
|
if self._commands_registered:
|
||||||
return
|
return
|
||||||
|
|
||||||
manager = self
|
|
||||||
runtime = self.runtime
|
runtime = self.runtime
|
||||||
|
|
||||||
def current_voice_channel(interaction: Any) -> Any | None:
|
def current_voice_channel(interaction: Any) -> Any | None:
|
||||||
|
|
@ -224,12 +296,13 @@ class DiscordGatewayManager:
|
||||||
raise ValueError(f"Query matched multiple titles. Refine it:\n{preview}")
|
raise ValueError(f"Query matched multiple titles. Refine it:\n{preview}")
|
||||||
return exact_matches[0] if exact_matches else items[0]
|
return exact_matches[0] if exact_matches else items[0]
|
||||||
|
|
||||||
group = discord.app_commands.Group(name="watchparty", description="Control Jellyfin watch parties in Discord")
|
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)
|
||||||
|
|
||||||
@group.command(name="create", description="Create a watch-party session for your current voice channel")
|
async def do_create(interaction: Any, title: str | None = None) -> None:
|
||||||
@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)
|
voice_channel = ensure_voice_context(interaction)
|
||||||
session = session_for_voice(interaction)
|
session = session_for_voice(interaction)
|
||||||
if session is None:
|
if session is None:
|
||||||
|
|
@ -245,9 +318,135 @@ class DiscordGatewayManager:
|
||||||
message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>."
|
message = f"Created `{session['title']}` for <#{session['voiceChannelId']}>."
|
||||||
else:
|
else:
|
||||||
message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>."
|
message = f"Using existing session `{session['title']}` for <#{session['voiceChannelId']}>."
|
||||||
await interaction.response.send_message(message, ephemeral=True)
|
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:
|
except Exception as exc:
|
||||||
await interaction.response.send_message(str(exc), ephemeral=True)
|
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:
|
||||||
|
await do_create(interaction, title)
|
||||||
|
except Exception as exc:
|
||||||
|
await respond(interaction, str(exc))
|
||||||
|
|
||||||
@group.command(name="search", description="Search the saved Jellyfin library for watch-party titles")
|
@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")
|
@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)
|
ensure_guild_context(interaction)
|
||||||
choice = media_type.strip().lower() if media_type.strip().lower() in {"all", "movie", "show"} else "all"
|
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)
|
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:
|
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")
|
@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")
|
@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:
|
async def add_command(interaction: Any, query: str, media_type: str = "all") -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=True, title="Watch Party")
|
await do_add(interaction, query, media_type)
|
||||||
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:
|
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")
|
@group.command(name="start", description="Connect the worker and start playback in your current voice channel")
|
||||||
async def start_command(interaction: Any) -> None:
|
async def start_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_start(interaction)
|
||||||
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:
|
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")
|
@group.command(name="pause", description="Pause the current watch-party stream")
|
||||||
async def pause_command(interaction: Any) -> None:
|
async def pause_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_control(interaction, "pause", {})
|
||||||
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:
|
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")
|
@group.command(name="resume", description="Resume the current watch-party stream")
|
||||||
async def resume_command(interaction: Any) -> None:
|
async def resume_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_control(interaction, "resume", {})
|
||||||
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:
|
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")
|
@group.command(name="stop", description="Stop the current watch-party stream")
|
||||||
async def stop_command(interaction: Any) -> None:
|
async def stop_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_control(interaction, "stop", {})
|
||||||
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:
|
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")
|
@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")
|
@discord.app_commands.describe(position_seconds="Target playback position in seconds")
|
||||||
async def seek_command(interaction: Any, position_seconds: int) -> None:
|
async def seek_command(interaction: Any, position_seconds: int) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_control(interaction, "seek", {"positionSeconds": max(0, position_seconds)})
|
||||||
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:
|
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")
|
@group.command(name="queue", description="Show the queue for your current voice channel watch party")
|
||||||
async def queue_command(interaction: Any) -> None:
|
async def queue_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_queue(interaction)
|
||||||
payload = get_watch_party_session(int(session["id"]))
|
|
||||||
await interaction.response.send_message(format_watch_party_summary(payload), ephemeral=True)
|
|
||||||
except Exception as exc:
|
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")
|
@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:
|
async def status_command(interaction: Any) -> None:
|
||||||
try:
|
try:
|
||||||
session = ensure_session(interaction, create_if_missing=False)
|
await do_status(interaction)
|
||||||
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:
|
except Exception as exc:
|
||||||
await interaction.response.send_message(str(exc), ephemeral=True)
|
await respond(interaction, str(exc))
|
||||||
|
|
||||||
tree.add_command(group)
|
tree.add_command(group)
|
||||||
|
self.client.add_view(WatchPartyPanelView())
|
||||||
self._commands_registered = True
|
self._commands_registered = True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -969,6 +969,9 @@ def media_item_card_payload(item: MediaItem, reason: str = "") -> dict[str, Any]
|
||||||
return {
|
return {
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"mediaType": item.media_type,
|
"mediaType": item.media_type,
|
||||||
|
"sourceId": item.source_id or "",
|
||||||
|
"tmdbId": item.tmdb_id or "",
|
||||||
|
"imdbId": item.imdb_id or "",
|
||||||
"posterUrl": catalog_poster_path(item),
|
"posterUrl": catalog_poster_path(item),
|
||||||
"year": item.year or "",
|
"year": item.year or "",
|
||||||
"genres": item.genres or "",
|
"genres": item.genres or "",
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,12 @@ def channel_settings(runtime: BotRuntime) -> dict[str, str]:
|
||||||
data = load_state(runtime.settings_path)
|
data = load_state(runtime.settings_path)
|
||||||
status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id
|
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
|
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()
|
catalog_url = str(data.get("catalog_url", "")).strip() or os.getenv("PUBLIC_CATALOG_URL", "").strip()
|
||||||
return {
|
return {
|
||||||
"statusChannelId": status_channel,
|
"statusChannelId": status_channel,
|
||||||
"mediaChannelId": media_channel,
|
"mediaChannelId": media_channel,
|
||||||
|
"watchPartyChannelId": watch_party_channel,
|
||||||
"catalogUrl": catalog_url,
|
"catalogUrl": catalog_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,15 +59,18 @@ def save_channel_settings(
|
||||||
runtime: BotRuntime,
|
runtime: BotRuntime,
|
||||||
status_channel_id: str,
|
status_channel_id: str,
|
||||||
media_channel_id: str,
|
media_channel_id: str,
|
||||||
|
watch_party_channel_id: str = "",
|
||||||
catalog_url: str = "",
|
catalog_url: str = "",
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
status_channel = validate_channel_id(status_channel_id, "Status")
|
status_channel = validate_channel_id(status_channel_id, "Status")
|
||||||
media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
|
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 = load_state(runtime.settings_path)
|
||||||
state.update(
|
state.update(
|
||||||
{
|
{
|
||||||
"status_channel_id": status_channel,
|
"status_channel_id": status_channel,
|
||||||
"media_channel_id": media_channel,
|
"media_channel_id": media_channel,
|
||||||
|
"watch_party_channel_id": watch_party_channel,
|
||||||
"catalog_url": validate_catalog_url(catalog_url),
|
"catalog_url": validate_catalog_url(catalog_url),
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
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
|
from .worker_client import worker_status
|
||||||
|
|
||||||
|
try:
|
||||||
worker = worker_status(session_id)
|
worker = worker_status(session_id)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "HTTP 404" in str(exc):
|
||||||
|
return session_payload
|
||||||
|
raise
|
||||||
result = update_worker_state(
|
result = update_worker_state(
|
||||||
session_id,
|
session_id,
|
||||||
worker_status=str(worker.get("workerStatus", "idle")),
|
worker_status=str(worker.get("workerStatus", "idle")),
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,7 @@
|
||||||
<h1>Watch Parties</h1>
|
<h1>Watch Parties</h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="watchRefresh" type="button">Refresh</button>
|
<button id="watchRefresh" type="button">Refresh</button>
|
||||||
|
<button id="watchPublishPanel" type="button">Publish Control Panel</button>
|
||||||
<button id="watchCreate" class="primary" type="button">Create session</button>
|
<button id="watchCreate" class="primary" type="button">Create session</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -834,6 +835,10 @@
|
||||||
<label for="watchTitle">Session Title</label>
|
<label for="watchTitle">Session Title</label>
|
||||||
<input id="watchTitle" placeholder="Friday Night Movie">
|
<input id="watchTitle" placeholder="Friday Night Movie">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="media-field">
|
||||||
|
<label for="watchPartyChannelId">Panel Channel ID</label>
|
||||||
|
<input id="watchPartyChannelId" placeholder="Discord text channel ID for control panel">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -923,7 +928,7 @@
|
||||||
let services = [];
|
let services = [];
|
||||||
let results = new Map();
|
let results = new Map();
|
||||||
let csrfToken = "";
|
let csrfToken = "";
|
||||||
let channels = { statusChannelId: "", mediaChannelId: "", catalogUrl: "" };
|
let channels = { statusChannelId: "", mediaChannelId: "", watchPartyChannelId: "", catalogUrl: "" };
|
||||||
let mediaLibrary = { movies: [], shows: [] };
|
let mediaLibrary = { movies: [], shows: [] };
|
||||||
let activeMediaTab = "movies";
|
let activeMediaTab = "movies";
|
||||||
let jellyfin = { url: "", configured: false, libraryNames: [], autoSync: false };
|
let jellyfin = { url: "", configured: false, libraryNames: [], autoSync: false };
|
||||||
|
|
@ -1040,9 +1045,11 @@
|
||||||
channels = payload.channels || {
|
channels = payload.channels || {
|
||||||
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
||||||
mediaChannelId: channels.mediaChannelId || payload.channelId || "",
|
mediaChannelId: channels.mediaChannelId || payload.channelId || "",
|
||||||
|
watchPartyChannelId: channels.watchPartyChannelId || channels.mediaChannelId || payload.channelId || "",
|
||||||
catalogUrl: channels.catalogUrl || ""
|
catalogUrl: channels.catalogUrl || ""
|
||||||
};
|
};
|
||||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||||
|
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || channels.mediaChannelId || "";
|
||||||
const online = payload.results.filter((result) => result.ok).length;
|
const online = payload.results.filter((result) => result.ok).length;
|
||||||
document.querySelector("#serviceCount").textContent = payload.services.length;
|
document.querySelector("#serviceCount").textContent = payload.services.length;
|
||||||
document.querySelector("#onlineCount").textContent = online;
|
document.querySelector("#onlineCount").textContent = online;
|
||||||
|
|
@ -1157,6 +1164,7 @@
|
||||||
channels = payload.channels;
|
channels = payload.channels;
|
||||||
}
|
}
|
||||||
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||||
|
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || channels.mediaChannelId || "";
|
||||||
if (payload.library) {
|
if (payload.library) {
|
||||||
mediaLibrary = normalizeMediaLibrary(payload.library);
|
mediaLibrary = normalizeMediaLibrary(payload.library);
|
||||||
renderMediaLibrary();
|
renderMediaLibrary();
|
||||||
|
|
@ -1549,6 +1557,16 @@
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishWatchPanel() {
|
||||||
|
await saveChannelSettings();
|
||||||
|
const payload = await api("/api/watchparty/panel/publish", {
|
||||||
|
method: "POST",
|
||||||
|
body: "{}"
|
||||||
|
});
|
||||||
|
setWatchMessage(`Published watch-party control panel to channel ${payload.channelId}.`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMediaItem(item = {}, mediaType = "movie") {
|
function normalizeMediaItem(item = {}, mediaType = "movie") {
|
||||||
return {
|
return {
|
||||||
title: item.title || "",
|
title: item.title || "",
|
||||||
|
|
@ -1657,8 +1675,9 @@
|
||||||
function currentChannelSettings() {
|
function currentChannelSettings() {
|
||||||
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
|
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
|
||||||
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
|
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
|
||||||
|
const watchPartyChannelId = document.querySelector("#watchPartyChannelId").value.trim() || channels.watchPartyChannelId || mediaChannelId;
|
||||||
const catalogUrl = document.querySelector("#catalogUrl").value.trim() || channels.catalogUrl || "";
|
const catalogUrl = document.querySelector("#catalogUrl").value.trim() || channels.catalogUrl || "";
|
||||||
return { statusChannelId, mediaChannelId, catalogUrl };
|
return { statusChannelId, mediaChannelId, watchPartyChannelId, catalogUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChannelSettings() {
|
async function saveChannelSettings() {
|
||||||
|
|
@ -1669,6 +1688,7 @@
|
||||||
channels = payload.channels || currentChannelSettings();
|
channels = payload.channels || currentChannelSettings();
|
||||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
||||||
|
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || "";
|
||||||
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
@ -1835,6 +1855,10 @@
|
||||||
createWatchSession().catch((error) => setWatchMessage(error.message, true));
|
createWatchSession().catch((error) => setWatchMessage(error.message, true));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector("#watchPublishPanel").addEventListener("click", () => {
|
||||||
|
publishWatchPanel().catch((error) => setWatchMessage(error.message, true));
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelector("#watchSearchRun").addEventListener("click", () => {
|
document.querySelector("#watchSearchRun").addEventListener("click", () => {
|
||||||
searchWatchMedia().catch((error) => setWatchSearchMessage(error.message, true));
|
searchWatchMedia().catch((error) => setWatchSearchMessage(error.message, true));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue