Implement subtitle selection support (burn-in via Jellyfin transcoding) with dynamic stream restarting

This commit is contained in:
MiTHRAL 2026-05-26 18:39:21 -04:00
parent f95c9c81b6
commit e7028c190b
5 changed files with 196 additions and 6 deletions

View file

@ -474,6 +474,98 @@ class DiscordGatewayManager:
except Exception as exc: except Exception as exc:
print(f"Failed to delete temporary channel {vc_id}: {exc}", flush=True) 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"): class WatchPartyAddModal(discord.ui.Modal, title="Add To Watch Party"):
query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120) query = discord.ui.TextInput(label="Title", placeholder="Alien", required=True, max_length=120)
media_type = discord.ui.TextInput( media_type = discord.ui.TextInput(
@ -593,6 +685,13 @@ class DiscordGatewayManager:
except Exception as exc: except Exception as exc:
await respond(interaction, str(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 = 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") @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: except Exception as exc:
await respond(interaction, str(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) tree.add_command(group)
self.client.add_view(WatchPartyPanelView()) self.client.add_view(WatchPartyPanelView())
self._commands_registered = True self._commands_registered = True

View file

@ -302,8 +302,13 @@ def media_item_duration_seconds(item: MediaItem) -> int:
return max(0, hours * 3600 + minutes * 60) return max(0, hours * 3600 + minutes * 60)
def jellyfin_stream_url(base_url: str, item_id: str, api_key: str) -> str: def jellyfin_stream_url(base_url: str, item_id: str, api_key: str, subtitle_index: int | None = None) -> str:
query = urllib.parse.urlencode({"static": "true", "api_key": api_key}) 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}" 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 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) settings = jellyfin_connection_settings(runtime)
base_url = str(settings.get("url", "")).strip().rstrip("/") base_url = str(settings.get("url", "")).strip().rstrip("/")
api_key = str(settings.get("apiKey", "")).strip() 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, "sourceId": item.source_id,
"title": playback_title, "title": playback_title,
"mediaType": item.media_type, "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), "downloadUrl": jellyfin_download_url(base_url, playback_item_id, api_key),
"durationSeconds": duration_seconds, "durationSeconds": duration_seconds,
"posterUrl": media_item_card_payload(item).get("posterUrl", ""), "posterUrl": media_item_card_payload(item).get("posterUrl", ""),

View file

@ -33,6 +33,7 @@ class WatchPartySession:
worker_session_id: str worker_session_id: str
current_queue_entry_id: int | None current_queue_entry_id: int | None
is_temp_channel: bool is_temp_channel: bool
subtitle_stream_index: int | None
created_at: str created_at: str
updated_at: str updated_at: str
@ -53,6 +54,7 @@ def initialize_watchparty_schema() -> None:
worker_session_id TEXT NOT NULL DEFAULT '', worker_session_id TEXT NOT NULL DEFAULT '',
current_queue_entry_id INTEGER, current_queue_entry_id INTEGER,
is_temp_channel INTEGER NOT NULL DEFAULT 0, is_temp_channel INTEGER NOT NULL DEFAULT 0,
subtitle_stream_index INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
@ -64,6 +66,11 @@ def initialize_watchparty_schema() -> None:
connection.commit() connection.commit()
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
connection.execute("ALTER TABLE watch_party_sessions ADD COLUMN subtitle_stream_index INTEGER")
connection.commit()
except sqlite3.OperationalError:
pass
connection.execute( connection.execute(
""" """
CREATE TABLE IF NOT EXISTS watch_party_queue ( 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 ""), 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, 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, 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"]), created_at=str(row["created_at"]),
updated_at=str(row["updated_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, "workerSessionId": session.worker_session_id,
"currentQueueEntryId": session.current_queue_entry_id, "currentQueueEntryId": session.current_queue_entry_id,
"isTempChannel": session.is_temp_channel, "isTempChannel": session.is_temp_channel,
"subtitleStreamIndex": session.subtitle_stream_index,
"createdAt": session.created_at, "createdAt": session.created_at,
"updatedAt": session.updated_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"])) media_item = catalog_item_for_source_id(runtime, str(queue_entry["jellyfinSourceId"]))
if media_item is None: if media_item is None:
raise ValueError(f"Queued media item no longer exists in the saved library: {queue_entry['jellyfinSourceId']}") 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( worker = worker_play(
session_id=session_id, session_id=session_id,
queue_entry_id=int(queue_entry["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} 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}

View file

@ -80,6 +80,7 @@ def worker_play(
title: str, title: str,
media_type: str, media_type: str,
playback: dict[str, Any], playback: dict[str, Any],
position_seconds: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
return worker_request( return worker_request(
"POST", "POST",
@ -90,6 +91,7 @@ def worker_play(
"title": title, "title": title,
"mediaType": media_type, "mediaType": media_type,
"playback": playback, "playback": playback,
"positionSeconds": position_seconds,
}, },
) )

View file

@ -545,7 +545,8 @@ const server = http.createServer(async (request, response) => {
session.currentTitle = String(body.title || "").trim(); session.currentTitle = String(body.title || "").trim();
session.mediaType = String(body.mediaType || "").trim(); session.mediaType = String(body.mediaType || "").trim();
try { try {
await startPlayback(session, body.playback); const startPosition = Math.max(0, Number(body.positionSeconds || 0) || 0);
await startPlayback(session, body.playback, startPosition);
} catch (error) { } catch (error) {
console.error("[worker] Error in play setup:", error); console.error("[worker] Error in play setup:", error);
session.lastError = error instanceof Error ? error.message : String(error); session.lastError = error instanceof Error ? error.message : String(error);