(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

View File

@@ -0,0 +1,221 @@
"""
Test rooms API endpoints in the Meet core app: start subtitle.
"""
# pylint: disable=W0621
import uuid
from unittest import mock
from django.conf import settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_room_id() -> str:
"""Mock room's id."""
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
@pytest.fixture
def mock_livekit_token(mock_room_id):
"""Mock LiveKit JWT token."""
video_grants = VideoGrants(
room=mock_room_id,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(str(uuid.uuid4()))
)
return token.to_jwt()
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_missing_token_anonymous(settings):
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_missing_token_authenticated(settings):
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_invalid_token():
"""Test that malformed or invalid LiveKit tokens are rejected."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, subtitles are disabled."}
def test_start_subtitle_valid_token(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test successful subtitle initiation with valid token and enabled feature."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "multi-user-transcriber"
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
def test_start_subtitle_twirp_error(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test handling of LiveKit service errors during subtitle initiation."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 500
assert response.json() == {
"error": f"Subtitles failed to start for room {room.slug}"
}
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
"""Test that tokens are validated against the correct room ID."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}

View File

@@ -0,0 +1,48 @@
"""
Test subtitle service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.subtitle import SubtitleService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_settings(mock_livekit_client, settings):
"""Test that start_subtitle uses the configured agent name from Django settings."""
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
room = RoomFactory(name="my room")
SubtitleService().start_subtitle(room)
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "fake-subtitle-agent-name"
assert call_args.room == str(room.id)
def test_stop_subtitle_not_implemented():
"""Test that stop_subtitle raises NotImplementedError."""
room = RoomFactory(name="my room")
with pytest.raises(
NotImplementedError, match="Subtitle agent stopping not yet implemented"
):
SubtitleService().stop_subtitle(room)