(backend) add start-subtitle endpoint

Allow any user, anonymous or authenticated, to start subtitling
in a room only if they are an active participant of it.

Subtitling a room consists of starting the multi-user transcriber agent.
This agent forwards all participants' audio to an STT server and returns
transcription segments for any active voice to the room.

User roles in the backend room system cannot be used
to determine subtitle permissions.

The transcriber agent can be triggered multiple times but will only join a
room once. Unicity is managed by the agent itself.
Any user with a valid LiveKit token can initiate subtitles. Feature flag
logic is implemented on the frontend. The frontend ensures the "start
subtitle" action is only available to users who should see it. The backend
does not enforce feature flags in this version.

Authentication in our system does not imply access to a room. The only
valid proof of access is the LiveKit API token issued by the backend.
Security consideration: A LiveKit API token is valid for 6 hours and
cannot be revoked at the end of a meeting. It is important to verify
that the token was issued for the correct room.

Calls to the agent dispatch endpoint must be server-initiated. The backend
proxies these calls, as clients cannot securely contact the agent dispatch
endpoint directly (per LiveKit documentation).

Room ID is passed as a query parameter. There is currently no validation
ensuring that the room exists prior to agent dispatch.
TODO: implement validation or error handling for non-existent rooms.

The backend does not forward LiveKit tokens to the agent. Default API
rate limiting is applied to prevent abuse.
This commit is contained in:
lebaudantoine
2025-08-19 15:29:19 +02:00
committed by aleb_the_flash
parent 49ee46438b
commit f48dd5cea1
9 changed files with 422 additions and 0 deletions
+1
View File
@@ -50,6 +50,7 @@ def get_frontend_configuration(request):
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
+19
View File
@@ -119,3 +119,22 @@ class IsStorageEventEnabled(permissions.BasePermission):
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
class IsSubtitleEnabled(permissions.BasePermission):
"""Check if the subtitle feature is enabled."""
message = "Access denied, subtitles are disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.ROOM_SUBTITLE_ENABLED
class HasLiveKitRoomAccess(permissions.BasePermission):
"""Check if authenticated user's LiveKit token is for the specific room."""
def has_object_permission(self, request, view, obj):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
+33
View File
@@ -51,7 +51,9 @@ from core.services.lobby import (
LobbyService,
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
# pylint: disable=too-many-ancestors
@@ -530,6 +532,37 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-subtitle",
permission_classes=[
permissions.IsSubtitleEnabled,
permissions.HasLiveKitRoomAccess,
],
authentication_classes=[LiveKitTokenAuthentication],
)
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
"""Start realtime transcription for the room.
Requires valid LiveKit token for room authorization.
Anonymous users can start subtitles if they have room access tokens.
"""
room = self.get_object()
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
return drf_response.Response(
{"error": f"Subtitles failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,