From e7028c190befe7a23163f5d73a098b590f0e1fad Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Tue, 26 May 2026 18:39:21 -0400 Subject: [PATCH] Implement subtitle selection support (burn-in via Jellyfin transcoding) with dynamic stream restarting --- archive_bot/discord_api.py | 106 +++++++++++++++++++++++++++++++++++ archive_bot/media.py | 13 +++-- archive_bot/watchparty.py | 78 +++++++++++++++++++++++++- archive_bot/worker_client.py | 2 + orb_stream_worker/server.js | 3 +- 5 files changed, 196 insertions(+), 6 deletions(-) diff --git a/archive_bot/discord_api.py b/archive_bot/discord_api.py index dc22753..5f336a0 100644 --- a/archive_bot/discord_api.py +++ b/archive_bot/discord_api.py @@ -474,6 +474,98 @@ class DiscordGatewayManager: except Exception as exc: print(f"Failed to delete temporary channel {vc_id}: {exc}", flush=True) + async def do_subs(interaction: Any) -> None: + session = ensure_session(interaction, create_if_missing=False) + + with connect_db() as connection: + row = connection.execute( + """ + SELECT q.jellyfin_source_id, q.title FROM watch_party_sessions s + JOIN watch_party_queue q ON s.current_queue_entry_id = q.id + WHERE s.id = ? + """, + (int(session["id"]),) + ).fetchone() + + if row is None: + raise ValueError("No item is currently playing") + + source_id = str(row["jellyfin_source_id"]) + title = str(row["title"]) + + settings = jellyfin_connection_settings(runtime) + data = await asyncio.to_thread(jellyfin_request, settings, f"/Items/{source_id}", {}) + + media_sources = data.get("MediaSources", []) + if not media_sources: + raise ValueError(f"No media sources found for {title}") + + sub_streams = [] + for source in media_sources: + for stream in source.get("MediaStreams", []): + if stream.get("Type") == "Subtitle": + sub_streams.append(stream) + + if not sub_streams: + await respond(interaction, f"No subtitle tracks found for `{title}`.") + return + + options = [ + discord.SelectOption( + label="Subtitles Off", + value="off", + description="Disable subtitles", + default=(session.get("subtitleStreamIndex") is None) + ) + ] + + for stream in sub_streams: + idx = stream.get("Index") + lang = stream.get("Language") or "und" + display = stream.get("DisplayTitle") or f"Track {idx}" + + options.append( + discord.SelectOption( + label=display[:80], + value=str(idx), + description=f"Language: {lang}", + default=(session.get("subtitleStreamIndex") == idx) + ) + ) + + class SubtitleSelect(discord.ui.Select): + def __init__(self) -> None: + super().__init__( + placeholder="Choose a subtitle track...", + min_values=1, + max_values=1, + options=options[:25] + ) + + async def callback(self, inter: Any) -> None: + val = self.values[0] + sub_idx = None if val == "off" else int(val) + + from .watchparty import change_watch_party_subtitles + await inter.response.defer(ephemeral=True) + + try: + await asyncio.to_thread(change_watch_party_subtitles, runtime, int(session["id"]), sub_idx) + label = "Disabled subtitles" if sub_idx is None else f"Switched subtitles to track {sub_idx}" + await inter.followup.send(f"{label} and restarted playback.", ephemeral=True) + except Exception as exc: + await inter.followup.send(f"Updated subtitles, but failed to restart stream: {exc}", ephemeral=True) + + class SubtitleView(discord.ui.View): + def __init__(self) -> None: + super().__init__(timeout=60) + self.add_item(SubtitleSelect()) + + if interaction.response.is_done(): + await interaction.followup.send("Select a subtitle track:", view=SubtitleView(), ephemeral=True) + else: + await interaction.response.send_message("Select a subtitle track:", view=SubtitleView(), ephemeral=True) + 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( @@ -593,6 +685,13 @@ class DiscordGatewayManager: except Exception as exc: await respond(interaction, str(exc)) + @discord.ui.button(label="Subtitles", style=discord.ButtonStyle.secondary, custom_id="watchparty_panel:subtitles", row=2) + async def subtitles_button(self, interaction: Any, _button: Any) -> None: + try: + await do_subs(interaction) + 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") @@ -686,6 +785,13 @@ class DiscordGatewayManager: except Exception as exc: await respond(interaction, str(exc)) + @group.command(name="subs", description="Select subtitles for the current playing movie/show") + async def subs_command(interaction: Any) -> None: + try: + await do_subs(interaction) + except Exception as exc: + await respond(interaction, str(exc)) + tree.add_command(group) self.client.add_view(WatchPartyPanelView()) self._commands_registered = True diff --git a/archive_bot/media.py b/archive_bot/media.py index d964b4c..9f2f424 100644 --- a/archive_bot/media.py +++ b/archive_bot/media.py @@ -302,8 +302,13 @@ def media_item_duration_seconds(item: MediaItem) -> int: return max(0, hours * 3600 + minutes * 60) -def jellyfin_stream_url(base_url: str, item_id: str, api_key: str) -> str: - query = urllib.parse.urlencode({"static": "true", "api_key": api_key}) +def jellyfin_stream_url(base_url: str, item_id: str, api_key: str, subtitle_index: int | None = None) -> str: + params = {"api_key": api_key} + if subtitle_index is not None: + params["SubtitleStreamIndex"] = str(subtitle_index) + else: + params["static"] = "true" + query = urllib.parse.urlencode(params) return f"{base_url}/Videos/{urllib.parse.quote(item_id, safe='')}/stream?{query}" @@ -335,7 +340,7 @@ def jellyfin_first_episode(settings: dict[str, Any], series_id: str) -> dict[str return None -def resolve_jellyfin_playback_source(runtime: BotRuntime, item: MediaItem) -> dict[str, Any]: +def resolve_jellyfin_playback_source(runtime: BotRuntime, item: MediaItem, subtitle_index: int | None = None) -> dict[str, Any]: settings = jellyfin_connection_settings(runtime) base_url = str(settings.get("url", "")).strip().rstrip("/") api_key = str(settings.get("apiKey", "")).strip() @@ -375,7 +380,7 @@ def resolve_jellyfin_playback_source(runtime: BotRuntime, item: MediaItem) -> di "sourceId": item.source_id, "title": playback_title, "mediaType": item.media_type, - "streamUrl": jellyfin_stream_url(base_url, playback_item_id, api_key), + "streamUrl": jellyfin_stream_url(base_url, playback_item_id, api_key, subtitle_index), "downloadUrl": jellyfin_download_url(base_url, playback_item_id, api_key), "durationSeconds": duration_seconds, "posterUrl": media_item_card_payload(item).get("posterUrl", ""), diff --git a/archive_bot/watchparty.py b/archive_bot/watchparty.py index a485553..2c04a56 100644 --- a/archive_bot/watchparty.py +++ b/archive_bot/watchparty.py @@ -33,6 +33,7 @@ class WatchPartySession: worker_session_id: str current_queue_entry_id: int | None is_temp_channel: bool + subtitle_stream_index: int | None created_at: str updated_at: str @@ -53,6 +54,7 @@ def initialize_watchparty_schema() -> None: worker_session_id TEXT NOT NULL DEFAULT '', current_queue_entry_id INTEGER, is_temp_channel INTEGER NOT NULL DEFAULT 0, + subtitle_stream_index INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) @@ -64,6 +66,11 @@ def initialize_watchparty_schema() -> None: connection.commit() except sqlite3.OperationalError: pass + try: + connection.execute("ALTER TABLE watch_party_sessions ADD COLUMN subtitle_stream_index INTEGER") + connection.commit() + except sqlite3.OperationalError: + pass connection.execute( """ CREATE TABLE IF NOT EXISTS watch_party_queue ( @@ -143,6 +150,7 @@ def watch_party_session_from_row(row: Any) -> WatchPartySession: worker_session_id=str(row["worker_session_id"] or ""), current_queue_entry_id=int(row["current_queue_entry_id"]) if row["current_queue_entry_id"] is not None else None, is_temp_channel=bool(row["is_temp_channel"]) if "is_temp_channel" in row.keys() else False, + subtitle_stream_index=int(row["subtitle_stream_index"]) if "subtitle_stream_index" in row.keys() and row["subtitle_stream_index"] is not None else None, created_at=str(row["created_at"]), updated_at=str(row["updated_at"]), ) @@ -160,6 +168,7 @@ def session_to_jsonable(session: WatchPartySession) -> dict[str, Any]: "workerSessionId": session.worker_session_id, "currentQueueEntryId": session.current_queue_entry_id, "isTempChannel": session.is_temp_channel, + "subtitleStreamIndex": session.subtitle_stream_index, "createdAt": session.created_at, "updatedAt": session.updated_at, } @@ -594,7 +603,9 @@ def play_next_watch_party_item(runtime: BotRuntime, session_id: int) -> dict[str 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) + session = get_watch_party_session(session_id)["session"] + subtitle_index = session.get("subtitleStreamIndex") + playback = resolve_jellyfin_playback_source(runtime, media_item, subtitle_index) worker = worker_play( session_id=session_id, queue_entry_id=int(queue_entry["id"]), @@ -739,3 +750,68 @@ def end_all_watch_party_sessions() -> dict[str, Any]: return {"ok": True, "endedCount": deleted_count} + +def change_watch_party_subtitles(runtime: BotRuntime, session_id: int, subtitle_index: int | None) -> dict[str, Any]: + from .media import catalog_item_for_source_id, resolve_jellyfin_playback_source + from .worker_client import worker_play, worker_control + + initialize_watchparty_schema() + with connect_db() as connection: + connection.execute( + "UPDATE watch_party_sessions SET subtitle_stream_index = ?, updated_at = ? WHERE id = ?", + (subtitle_index, utc_now(), session_id), + ) + connection.commit() + + session_payload = get_watch_party_session(session_id) + session = session_payload["session"] + queue_entry_id = session.get("currentQueueEntryId") + if queue_entry_id is None: + return session_payload + + with connect_db() as connection: + queue_entry = connection.execute( + "SELECT * FROM watch_party_queue WHERE id = ?", + (queue_entry_id,), + ).fetchone() + + if queue_entry is None: + return session_payload + + worker_status_payload = refresh_watch_party_worker(session_id) + worker = worker_status_payload.get("worker", {}) + pos = int(worker.get("positionSeconds", 0) or 0) + + media_item = catalog_item_for_source_id(runtime, str(queue_entry["jellyfin_source_id"])) + if media_item is None: + return session_payload + + playback = resolve_jellyfin_playback_source(runtime, media_item, subtitle_index) + + try: + worker_control(session_id=session_id, action="stop") + except Exception: + pass + + worker = worker_play( + session_id=session_id, + queue_entry_id=int(queue_entry["id"]), + jellyfin_source_id=str(queue_entry["jellyfin_source_id"]), + title=str(queue_entry["title"]), + media_type=str(queue_entry["media_type"]), + playback=playback, + position_seconds=pos, + ) + + 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} + diff --git a/archive_bot/worker_client.py b/archive_bot/worker_client.py index 8b45da6..f4cdd20 100644 --- a/archive_bot/worker_client.py +++ b/archive_bot/worker_client.py @@ -80,6 +80,7 @@ def worker_play( title: str, media_type: str, playback: dict[str, Any], + position_seconds: int = 0, ) -> dict[str, Any]: return worker_request( "POST", @@ -90,6 +91,7 @@ def worker_play( "title": title, "mediaType": media_type, "playback": playback, + "positionSeconds": position_seconds, }, ) diff --git a/orb_stream_worker/server.js b/orb_stream_worker/server.js index 8b1c286..782ed0f 100644 --- a/orb_stream_worker/server.js +++ b/orb_stream_worker/server.js @@ -545,7 +545,8 @@ const server = http.createServer(async (request, response) => { session.currentTitle = String(body.title || "").trim(); session.mediaType = String(body.mediaType || "").trim(); try { - await startPlayback(session, body.playback); + const startPosition = Math.max(0, Number(body.positionSeconds || 0) || 0); + await startPlayback(session, body.playback, startPosition); } catch (error) { console.error("[worker] Error in play setup:", error); session.lastError = error instanceof Error ? error.message : String(error);