Implement subtitle selection support (burn-in via Jellyfin transcoding) with dynamic stream restarting
This commit is contained in:
parent
f95c9c81b6
commit
e7028c190b
5 changed files with 196 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue