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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,12 +296,13 @@ class DiscordGatewayManager:
|
|||
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")
|
||||
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")
|
||||
@discord.app_commands.describe(title="Optional session title")
|
||||
async def create_command(interaction: Any, title: str | None = None) -> None:
|
||||
try:
|
||||
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:
|
||||
|
|
@ -245,9 +318,135 @@ class DiscordGatewayManager:
|
|||
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 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 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")
|
||||
@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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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")),
|
||||
|
|
|
|||
|
|
@ -803,6 +803,7 @@
|
|||
<h1>Watch Parties</h1>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -834,6 +835,10 @@
|
|||
<label for="watchTitle">Session Title</label>
|
||||
<input id="watchTitle" placeholder="Friday Night Movie">
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
|
@ -923,7 +928,7 @@
|
|||
let services = [];
|
||||
let results = new Map();
|
||||
let csrfToken = "";
|
||||
let channels = { statusChannelId: "", mediaChannelId: "", catalogUrl: "" };
|
||||
let channels = { statusChannelId: "", mediaChannelId: "", watchPartyChannelId: "", catalogUrl: "" };
|
||||
let mediaLibrary = { movies: [], shows: [] };
|
||||
let activeMediaTab = "movies";
|
||||
let jellyfin = { url: "", configured: false, libraryNames: [], autoSync: false };
|
||||
|
|
@ -1040,9 +1045,11 @@
|
|||
channels = payload.channels || {
|
||||
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
||||
mediaChannelId: channels.mediaChannelId || payload.channelId || "",
|
||||
watchPartyChannelId: channels.watchPartyChannelId || channels.mediaChannelId || payload.channelId || "",
|
||||
catalogUrl: channels.catalogUrl || ""
|
||||
};
|
||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || channels.mediaChannelId || "";
|
||||
const online = payload.results.filter((result) => result.ok).length;
|
||||
document.querySelector("#serviceCount").textContent = payload.services.length;
|
||||
document.querySelector("#onlineCount").textContent = online;
|
||||
|
|
@ -1157,6 +1164,7 @@
|
|||
channels = payload.channels;
|
||||
}
|
||||
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || channels.mediaChannelId || "";
|
||||
if (payload.library) {
|
||||
mediaLibrary = normalizeMediaLibrary(payload.library);
|
||||
renderMediaLibrary();
|
||||
|
|
@ -1549,6 +1557,16 @@
|
|||
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") {
|
||||
return {
|
||||
title: item.title || "",
|
||||
|
|
@ -1657,8 +1675,9 @@
|
|||
function currentChannelSettings() {
|
||||
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.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 || "";
|
||||
return { statusChannelId, mediaChannelId, catalogUrl };
|
||||
return { statusChannelId, mediaChannelId, watchPartyChannelId, catalogUrl };
|
||||
}
|
||||
|
||||
async function saveChannelSettings() {
|
||||
|
|
@ -1669,6 +1688,7 @@
|
|||
channels = payload.channels || currentChannelSettings();
|
||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
||||
document.querySelector("#watchPartyChannelId").value = channels.watchPartyChannelId || "";
|
||||
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||
return channels;
|
||||
}
|
||||
|
|
@ -1835,6 +1855,10 @@
|
|||
createWatchSession().catch((error) => setWatchMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#watchPublishPanel").addEventListener("click", () => {
|
||||
publishWatchPanel().catch((error) => setWatchMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#watchSearchRun").addEventListener("click", () => {
|
||||
searchWatchMedia().catch((error) => setWatchSearchMessage(error.message, true));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue