Add Discord watch party commands

This commit is contained in:
MiTHRAL 2026-05-26 15:25:19 -04:00
parent 3987867d71
commit fbf2264041
4 changed files with 425 additions and 92 deletions

View file

@ -38,24 +38,22 @@ from .storage import (
) )
from .watchparty import ( from .watchparty import (
add_watch_party_queue_item, add_watch_party_queue_item,
control_watch_party_worker,
create_watch_party_session, create_watch_party_session,
find_watch_party_session,
first_queued_entry, first_queued_entry,
get_watch_party_session, get_watch_party_session,
list_media_candidates, list_media_candidates,
list_watch_party_sessions, list_watch_party_sessions,
play_next_watch_party_item,
refresh_watch_party_worker,
start_watch_party_worker,
update_queue_entry_status, update_queue_entry_status,
update_watch_party_status, update_watch_party_status,
update_worker_state, update_worker_state,
worker_command_payload, worker_command_payload,
) )
from .worker_client import ( from .worker_client import worker_enabled, worker_health
worker_control,
worker_create_session,
worker_enabled,
worker_health,
worker_play,
worker_status,
)
def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]: def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]:
@ -654,29 +652,8 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
try: try:
data = self.read_json() data = self.read_json()
session_id = int(str(data.get("sessionId", "")).strip()) session_id = int(str(data.get("sessionId", "")).strip())
session_payload = get_watch_party_session(session_id) result = start_watch_party_worker(session_id)
session = session_payload["session"] worker = result["worker"]
worker = worker_create_session(
session_id=session_id,
guild_id=str(session["guildId"]),
voice_channel_id=str(session["voiceChannelId"]),
text_channel_id=str(session["textChannelId"]),
)
result = update_watch_party_status(
session_id,
"connecting",
worker_session_id=str(worker.get("workerSessionId", session_id)),
current_queue_entry_id=session["currentQueueEntryId"],
)
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
except Exception as exc: except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return return
@ -687,36 +664,9 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
try: try:
data = self.read_json() data = self.read_json()
session_id = int(str(data.get("sessionId", "")).strip()) session_id = int(str(data.get("sessionId", "")).strip())
queue_entry = first_queued_entry(session_id) result = play_next_watch_party_item(runtime, session_id)
if queue_entry is None: worker = result["worker"]
raise ValueError("No queued items available") playback = result["playback"]
media_item = catalog_item_for_source_id(runtime, str(queue_entry["jellyfinSourceId"]))
if media_item is None:
raise ValueError(f"Queued media item no longer exists in the saved library: {queue_entry['jellyfinSourceId']}")
playback = resolve_jellyfin_playback_source(runtime, media_item)
worker = worker_play(
session_id=session_id,
queue_entry_id=int(queue_entry["id"]),
jellyfin_source_id=str(queue_entry["jellyfinSourceId"]),
title=str(queue_entry["title"]),
media_type=str(queue_entry["mediaType"]),
playback=playback,
)
update_queue_entry_status(session_id, int(queue_entry["id"]), "playing")
update_watch_party_status(
session_id,
"playing",
current_queue_entry_id=int(queue_entry["id"]),
)
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
except Exception as exc: except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return return
@ -731,22 +681,8 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
command_data = data.get("data", {}) command_data = data.get("data", {})
if not isinstance(command_data, dict): if not isinstance(command_data, dict):
raise ValueError("Worker control data must be an object") raise ValueError("Worker control data must be an object")
worker = worker_control(session_id=session_id, action=action, data=command_data) result = control_watch_party_worker(session_id, action, command_data)
if action == "pause": worker = result["worker"]
update_watch_party_status(session_id, "paused")
elif action == "resume":
update_watch_party_status(session_id, "playing")
elif action == "stop":
update_watch_party_status(session_id, "stopped")
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
except Exception as exc: except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return return
@ -757,16 +693,8 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
try: try:
data = self.read_json() data = self.read_json()
session_id = int(str(data.get("sessionId", "")).strip()) session_id = int(str(data.get("sessionId", "")).strip())
worker = worker_status(session_id) result = refresh_watch_party_worker(session_id)
result = update_worker_state( worker = result["worker"]
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
except Exception as exc: except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return return

View file

@ -9,17 +9,70 @@ import urllib.error
import urllib.request import urllib.request
from typing import Any from typing import Any
from .core import DISCORD_API from .core import BotRuntime, DISCORD_API
from .watchparty import (
add_watch_party_queue_item,
control_watch_party_worker,
create_watch_party_session,
find_watch_party_session,
get_watch_party_session,
list_media_candidates,
play_next_watch_party_item,
refresh_watch_party_worker,
start_watch_party_worker,
)
from .worker_client import worker_enabled
WATCH_PARTY_ACTIVE_STATUSES = {"draft", "queued", "connecting", "playing", "paused"}
def format_watch_party_summary(payload: dict[str, Any]) -> str:
session = payload.get("session", {})
worker = payload.get("worker") or {}
queue = payload.get("queue") or []
lines = [
f"Session `{session.get('title', 'Watch Party')}`",
f"Status: `{session.get('status', 'unknown')}`",
f"Worker: `{worker.get('workerStatus', 'idle')}` · Playback: `{worker.get('playbackState', 'idle')}`",
]
current_title = str(worker.get("currentTitle", "") or "").strip()
if current_title:
lines.append(f"Now playing: `{current_title}`")
if queue:
preview = []
for entry in queue[:5]:
preview.append(f"{entry.get('position', '?')}. {entry.get('title', 'Untitled')} [{entry.get('status', 'queued')}]")
lines.append("Queue:\n" + "\n".join(preview))
if len(queue) > 5:
lines.append(f"...and {len(queue) - 5} more")
else:
lines.append("Queue: empty")
return "\n".join(lines)
def format_media_search_results(items: list[dict[str, Any]]) -> str:
if not items:
return "No matches found."
lines: list[str] = []
for item in items[:8]:
details = [str(item.get("mediaType", "")), str(item.get("year", "")), str(item.get("genres", ""))]
meta = " · ".join(part for part in details if part)
lines.append(f"- `{item.get('title', 'Untitled')}`{f'{meta}' if meta else ''}")
return "\n".join(lines)
class DiscordGatewayManager: class DiscordGatewayManager:
def __init__(self, token: str) -> None: def __init__(self, token: str, runtime: BotRuntime) -> None:
self.token = token self.token = token
self.runtime = runtime
self.thread: threading.Thread | None = None self.thread: threading.Thread | None = None
self.loop: asyncio.AbstractEventLoop | None = None self.loop: asyncio.AbstractEventLoop | None = None
self.client: Any = None self.client: Any = None
self.ready = threading.Event() self.ready = threading.Event()
self._disconnecting = threading.Event() self._disconnecting = threading.Event()
self._commands_registered = False
self._commands_synced = False
def start(self) -> None: def start(self) -> None:
if self.thread is not None: if self.thread is not None:
@ -50,14 +103,20 @@ class DiscordGatewayManager:
def __init__(self, manager: DiscordGatewayManager) -> None: def __init__(self, manager: DiscordGatewayManager) -> None:
intents = discord.Intents.default() intents = discord.Intents.default()
intents.guilds = True intents.guilds = True
intents.voice_states = True
super().__init__(intents=intents) super().__init__(intents=intents)
self.manager = manager self.manager = manager
self.tree = discord.app_commands.CommandTree(self)
async def setup_hook(self) -> None:
self.manager.register_watchparty_commands(self.tree, discord)
async def on_ready(self) -> None: async def on_ready(self) -> None:
await self.change_presence(status=discord.Status.online) await self.change_presence(status=discord.Status.online)
user = self.user user = self.user
name = user.name if user is not None else "unknown" name = user.name if user is not None else "unknown"
bot_id = user.id if user is not None else "unknown" bot_id = user.id if user is not None else "unknown"
await self.manager.sync_commands(self.tree, discord)
print(f"Discord gateway connected as {name} ({bot_id})", flush=True) print(f"Discord gateway connected as {name} ({bot_id})", flush=True)
self.manager.ready.set() self.manager.ready.set()
@ -84,6 +143,221 @@ class DiscordGatewayManager:
finally: finally:
self.loop.close() self.loop.close()
async def sync_commands(self, tree: Any, discord: Any) -> None:
if self._commands_synced:
return
try:
await tree.sync()
for guild in self.client.guilds if self.client is not None else []:
tree.copy_global_to(guild=guild)
await tree.sync(guild=guild)
except Exception as exc:
print(f"Discord command sync failed: {exc}", file=sys.stderr, flush=True)
else:
self._commands_synced = True
def register_watchparty_commands(self, tree: Any, discord: Any) -> None:
if self._commands_registered:
return
manager = self
runtime = self.runtime
def current_voice_channel(interaction: Any) -> Any | None:
user = interaction.user
voice = getattr(user, "voice", None)
return getattr(voice, "channel", None)
def ensure_guild_context(interaction: Any) -> None:
if interaction.guild is None:
raise ValueError("This command only works inside a server")
def ensure_voice_context(interaction: Any) -> Any:
ensure_guild_context(interaction)
voice_channel = current_voice_channel(interaction)
if voice_channel is None:
raise ValueError("Join a voice channel first")
return voice_channel
def session_for_voice(interaction: Any) -> dict[str, Any] | None:
voice_channel = current_voice_channel(interaction)
if interaction.guild is None or voice_channel is None:
return None
session = find_watch_party_session(
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
allowed_statuses=WATCH_PARTY_ACTIVE_STATUSES,
)
if session is not None:
return session
return find_watch_party_session(
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
allowed_statuses=None,
)
def ensure_session(interaction: Any, *, create_if_missing: bool = False, title: str = "Watch Party") -> dict[str, Any]:
ensure_guild_context(interaction)
voice_channel = ensure_voice_context(interaction)
session = session_for_voice(interaction)
if session is not None:
return session
if not create_if_missing:
raise ValueError("No watch-party session exists for your voice channel. Run /watchparty create first.")
payload = create_watch_party_session(
runtime,
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
text_channel_id=str(interaction.channel_id),
owner_user_id=str(interaction.user.id),
title=title,
)
return payload["session"]
def pick_media_item(query: str, media_type: str) -> dict[str, Any]:
items = list_media_candidates(runtime, media_type=media_type, search=query, limit=8)
if not items:
raise ValueError("No library matches found for that query")
exact_matches = [item for item in items if str(item.get("title", "")).casefold() == query.strip().casefold()]
if len(items) > 1 and not exact_matches:
preview = format_media_search_results(items[:5])
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")
@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:
voice_channel = ensure_voice_context(interaction)
session = session_for_voice(interaction)
if session is None:
payload = create_watch_party_session(
runtime,
guild_id=str(interaction.guild.id),
voice_channel_id=str(voice_channel.id),
text_channel_id=str(interaction.channel_id),
owner_user_id=str(interaction.user.id),
title=(title or f"{voice_channel.name} Watch Party").strip(),
)
session = payload["session"]
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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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")
async def search_command(interaction: Any, query: str, media_type: str = "all") -> None:
try:
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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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,
)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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,
)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
@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)
except Exception as exc:
await interaction.response.send_message(str(exc), ephemeral=True)
tree.add_command(group)
self._commands_registered = True
def discord_request( def discord_request(
method: str, method: str,
@ -175,4 +449,3 @@ def discord_delete_message(token: str, channel_id: str, message_id: str) -> None
def discord_bot_identity(token: str) -> dict[str, Any]: def discord_bot_identity(token: str) -> dict[str, Any]:
return discord_request("GET", token, "/users/@me") return discord_request("GET", token, "/users/@me")

View file

@ -62,7 +62,7 @@ def main() -> int:
) )
gateway = None gateway = None
if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run: if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
gateway = DiscordGatewayManager(token) gateway = DiscordGatewayManager(token, runtime)
gateway.start() gateway.start()
dashboard = maybe_start_dashboard(runtime) dashboard = maybe_start_dashboard(runtime)

View file

@ -7,7 +7,13 @@ from typing import Any
from .core import BotRuntime, MediaItem from .core import BotRuntime, MediaItem
from .db import connect_db, initialize_database from .db import connect_db, initialize_database
from .media import load_media_library, media_item_card_payload from .media import (
catalog_item_for_source_id,
load_media_library,
media_item_card_payload,
resolve_jellyfin_playback_source,
)
from .worker_client import worker_control, worker_create_session, worker_status, worker_play
WATCH_PARTY_STATUSES = { WATCH_PARTY_STATUSES = {
"draft", "draft",
@ -279,6 +285,37 @@ def list_watch_party_sessions() -> list[dict[str, Any]]:
return [session_to_jsonable(watch_party_session_from_row(row)) for row in rows] return [session_to_jsonable(watch_party_session_from_row(row)) for row in rows]
def find_watch_party_session(
*,
guild_id: str,
voice_channel_id: str | None = None,
allowed_statuses: set[str] | None = None,
) -> dict[str, Any] | None:
initialize_watchparty_schema()
clauses = ["guild_id = ?"]
values: list[Any] = [guild_id.strip()]
if voice_channel_id is not None:
clauses.append("voice_channel_id = ?")
values.append(voice_channel_id.strip())
if allowed_statuses:
placeholders = ", ".join("?" for _ in allowed_statuses)
clauses.append(f"status IN ({placeholders})")
values.extend(sorted(allowed_statuses))
query = f"""
SELECT *
FROM watch_party_sessions
WHERE {' AND '.join(clauses)}
ORDER BY updated_at DESC, id DESC
LIMIT 1
"""
with connect_db() as connection:
row = connection.execute(query, tuple(values)).fetchone()
if row is None:
return None
return session_to_jsonable(watch_party_session_from_row(row))
def get_watch_party_session(session_id: int) -> dict[str, Any]: def get_watch_party_session(session_id: int) -> dict[str, Any]:
initialize_watchparty_schema() initialize_watchparty_schema()
with connect_db() as connection: with connect_db() as connection:
@ -506,3 +543,98 @@ def first_queued_entry(session_id: int) -> dict[str, Any] | None:
(session_id,), (session_id,),
).fetchone() ).fetchone()
return queue_entry_to_jsonable(row) if row is not None else None return queue_entry_to_jsonable(row) if row is not None else None
def start_watch_party_worker(session_id: int) -> dict[str, Any]:
session_payload = get_watch_party_session(session_id)
session = session_payload["session"]
worker = worker_create_session(
session_id=session_id,
guild_id=str(session["guildId"]),
voice_channel_id=str(session["voiceChannelId"]),
text_channel_id=str(session["textChannelId"]),
)
update_watch_party_status(
session_id,
"connecting",
worker_session_id=str(worker.get("workerSessionId", session_id)),
current_queue_entry_id=session["currentQueueEntryId"],
)
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
return {"worker": worker, **result}
def play_next_watch_party_item(runtime: BotRuntime, session_id: int) -> dict[str, Any]:
queue_entry = first_queued_entry(session_id)
if queue_entry is None:
raise ValueError("No queued items available")
media_item = catalog_item_for_source_id(runtime, str(queue_entry["jellyfinSourceId"]))
if media_item is None:
raise ValueError(f"Queued media item no longer exists in the saved library: {queue_entry['jellyfinSourceId']}")
playback = resolve_jellyfin_playback_source(runtime, media_item)
worker = worker_play(
session_id=session_id,
queue_entry_id=int(queue_entry["id"]),
jellyfin_source_id=str(queue_entry["jellyfinSourceId"]),
title=str(queue_entry["title"]),
media_type=str(queue_entry["mediaType"]),
playback=playback,
)
update_queue_entry_status(session_id, int(queue_entry["id"]), "playing")
update_watch_party_status(
session_id,
"playing",
current_queue_entry_id=int(queue_entry["id"]),
)
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
return {"worker": worker, "playback": playback, **result}
def control_watch_party_worker(session_id: int, action: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
worker = worker_control(session_id=session_id, action=action, data=data or {})
if action == "pause":
update_watch_party_status(session_id, "paused")
elif action == "resume":
update_watch_party_status(session_id, "playing")
elif action == "stop":
update_watch_party_status(session_id, "stopped")
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
return {"worker": worker, **result}
def refresh_watch_party_worker(session_id: int) -> dict[str, Any]:
worker = worker_status(session_id)
result = update_worker_state(
session_id,
worker_status=str(worker.get("workerStatus", "idle")),
playback_state=str(worker.get("playbackState", "idle")),
current_title=str(worker.get("currentTitle", "")),
position_seconds=int(worker.get("positionSeconds", 0) or 0),
duration_seconds=int(worker.get("durationSeconds", 0) or 0),
last_error=str(worker.get("lastError", "")),
)
return {"worker": worker, **result}