Fix watch party queue flow and add control panel

This commit is contained in:
MiTHRAL 2026-05-26 15:35:45 -04:00
parent e6ca8531ca
commit 5b356c2cf6
6 changed files with 295 additions and 76 deletions

View file

@ -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

View file

@ -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

View file

@ -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 "",

View file

@ -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(),
}

View file

@ -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")),

View file

@ -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));
});