Skip to content

Commit aec451b

Browse files
bgrozevclaude
andauthored
Fix transcription enabled before colibri session is initialized (#1274)
* fix: Apply custom transcription headers/params when session is created before setTranscriberUrl. When transcriberUrl is set before any Colibri session exists, getOrCreateSession was passing the URL to Colibri2Session but not the custom headers/params. The initial sendAllocationRequest then used only the static config headers and no URL params. Fix by passing headers/params to the constructor and using them in createRequest(create=true). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: Add tests for custom transcription headers and URL params. - TranscriptionConfigTest: covers processTranscriptionMetadata (null input, headers-only, params-only, both, and custom-overrides-base merging). - ColibriTranscriptionTest: verifies that custom headers and URL params reach the ConferenceModifyIQ Connect in both paths: transcription configured before any Colibri session exists (the bug fix) and after a session already exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5f338d3 commit aec451b

4 files changed

Lines changed: 296 additions & 4 deletions

File tree

jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/Colibri2Session.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ class Colibri2Session(
6060
// Whether the session was constructed for the purpose of visitor nodes
6161
val visitor: Boolean,
6262
private var transcriberUrl: TemplatedUrl?,
63-
parentLogger: Logger
63+
parentLogger: Logger,
64+
private var transcriberCustomHeaders: Map<String, String>? = null,
65+
private var transcriberUrlParams: Map<String, String>? = null
6466
) : CascadeNode<Colibri2Session, Colibri2Session.Relay> {
6567
private val logger = createChildLogger(parentLogger).apply {
6668
bridge.jid.resourceOrNull?.toString()?.let { addContext("bridge", it) }
@@ -200,12 +202,21 @@ class Colibri2Session(
200202
setConferenceName(colibriSessionManager.conferenceName)
201203
setRtcstatsEnabled(colibriSessionManager.rtcStatsEnabled)
202204
transcriberUrl?.let {
203-
val url = resolveTranscriberUrl(it)
205+
var url = resolveTranscriberUrl(it)
206+
val urlParams = transcriberUrlParams
207+
if (urlParams != null && urlParams.isNotEmpty()) {
208+
val queryString = urlParams.entries.joinToString("&") { (key, value) ->
209+
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
210+
}
211+
val separator = if (url.query == null) "?" else "&"
212+
url = java.net.URI(url.toString() + separator + queryString)
213+
}
214+
val headers = transcriberCustomHeaders ?: TranscriptionConfig.config.httpHeaders
204215
logger.info("Adding connect for transcriber, url=$url")
205216
addConnect(
206217
createConnect(
207218
url,
208-
TranscriptionConfig.config.httpHeaders,
219+
headers,
209220
TranscriptionConfig.config.pingEnabled,
210221
TranscriptionConfig.config.pingInterval.toMillis().toInt(),
211222
TranscriptionConfig.config.pingTimeout.toMillis().toInt()
@@ -226,6 +237,8 @@ class Colibri2Session(
226237
) {
227238
if (transcriberUrl != urlTemplate) {
228239
transcriberUrl = urlTemplate
240+
transcriberCustomHeaders = customHeaders
241+
transcriberUrlParams = urlParams
229242
val request = createRequest(create = false)
230243
if (urlTemplate != null) {
231244
var url = resolveTranscriberUrl(urlTemplate)

jicofo-selector/src/main/kotlin/org/jitsi/jicofo/bridge/colibri/ColibriV2SessionManager.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ class ColibriV2SessionManager(
272272
bridge,
273273
visitor,
274274
if (enableTranscriber) transcriberUrl else null,
275-
logger
275+
logger,
276+
if (enableTranscriber) transcriberCustomHeaders else null,
277+
if (enableTranscriber) transcriberUrlParams else null
276278
)
277279
if (enableTranscriber) {
278280
transcriberSession = session

jicofo-selector/src/test/kotlin/org/jitsi/jicofo/TranscriptionConfigTest.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package org.jitsi.jicofo
2020
import io.kotest.core.spec.style.ShouldSpec
2121
import io.kotest.matchers.shouldBe
2222
import org.jitsi.config.withNewConfig
23+
import org.jitsi.jicofo.xmpp.RoomMetadata
2324
import java.time.Duration
2425

2526
class TranscriptionConfigTest : ShouldSpec() {
@@ -105,6 +106,68 @@ class TranscriptionConfigTest : ShouldSpec() {
105106
}
106107
}
107108

109+
context("processTranscriptionMetadata") {
110+
val baseHeaders = mapOf("Base-Header" to "base-value", "Shared-Header" to "base-shared")
111+
112+
context("With null transcription") {
113+
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(null, baseHeaders)
114+
should("return null headers") { headers shouldBe null }
115+
should("return null params") { params shouldBe null }
116+
}
117+
118+
context("With transcription having no headers or params") {
119+
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
120+
RoomMetadata.Metadata.Transcription(),
121+
baseHeaders
122+
)
123+
should("return null headers") { headers shouldBe null }
124+
should("return null params") { params shouldBe null }
125+
}
126+
127+
context("With URL params only") {
128+
val urlParams = mapOf("key1" to "value1", "key2" to "value2")
129+
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
130+
RoomMetadata.Metadata.Transcription(urlParams = urlParams),
131+
baseHeaders
132+
)
133+
should("return null headers") { headers shouldBe null }
134+
should("return the URL params") { params shouldBe urlParams }
135+
}
136+
137+
context("With custom headers only") {
138+
val customHeaders = mapOf("X-Custom" to "custom-value", "Shared-Header" to "custom-shared")
139+
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
140+
RoomMetadata.Metadata.Transcription(httpHeaders = customHeaders),
141+
baseHeaders
142+
)
143+
should("return merged headers with custom taking precedence") {
144+
headers shouldBe mapOf(
145+
"Base-Header" to "base-value",
146+
"Shared-Header" to "custom-shared",
147+
"X-Custom" to "custom-value"
148+
)
149+
}
150+
should("return null params") { params shouldBe null }
151+
}
152+
153+
context("With both custom headers and URL params") {
154+
val customHeaders = mapOf("X-Custom" to "custom-value")
155+
val urlParams = mapOf("param1" to "v1")
156+
val (headers, params) = TranscriptionConfig.processTranscriptionMetadata(
157+
RoomMetadata.Metadata.Transcription(urlParams = urlParams, httpHeaders = customHeaders),
158+
baseHeaders
159+
)
160+
should("return merged headers") {
161+
headers shouldBe mapOf(
162+
"Base-Header" to "base-value",
163+
"Shared-Header" to "base-shared",
164+
"X-Custom" to "custom-value"
165+
)
166+
}
167+
should("return URL params") { params shouldBe urlParams }
168+
}
169+
}
170+
108171
context("ping") {
109172
context("With default values") {
110173
withNewConfig(
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Jicofo, the Jitsi Conference Focus.
3+
*
4+
* Copyright @ 2026 - present 8x8, Inc
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.jitsi.jicofo.bridge.colibri
19+
20+
import io.kotest.core.spec.IsolationMode
21+
import io.kotest.core.spec.style.ShouldSpec
22+
import io.kotest.core.test.TestCase
23+
import io.kotest.core.test.TestResult
24+
import io.kotest.matchers.shouldBe
25+
import io.kotest.matchers.shouldNotBe
26+
import io.kotest.matchers.string.shouldContain
27+
import io.mockk.every
28+
import io.mockk.mockk
29+
import org.jitsi.jicofo.TaskPools
30+
import org.jitsi.jicofo.bridge.Bridge
31+
import org.jitsi.jicofo.bridge.BridgeSelector
32+
import org.jitsi.jicofo.conference.inPlaceExecutor
33+
import org.jitsi.jicofo.conference.inPlaceScheduledExecutor
34+
import org.jitsi.jicofo.conference.source.EndpointSourceSet
35+
import org.jitsi.jicofo.mock.MockXmppConnection
36+
import org.jitsi.jicofo.mock.TestColibri2Server
37+
import org.jitsi.utils.OrderedJsonObject
38+
import org.jitsi.utils.TemplatedUrl
39+
import org.jitsi.utils.logging2.createLogger
40+
import org.jitsi.xmpp.extensions.colibri2.ConferenceModifyIQ
41+
import org.jivesoftware.smack.packet.IQ
42+
import org.jxmpp.jid.impl.JidCreate
43+
44+
/**
45+
* Tests that custom transcription HTTP headers and URL parameters are correctly included in Colibri2 requests,
46+
* covering both the case where transcription is configured before the first session is created (bug fix) and
47+
* after a session already exists.
48+
*/
49+
class ColibriTranscriptionTest : ShouldSpec() {
50+
override fun isolationMode() = IsolationMode.InstancePerLeaf
51+
52+
private val colibriRequests = mutableListOf<ConferenceModifyIQ>()
53+
private val colibri2Server = TestColibri2Server()
54+
private val xmppConnection = object : MockXmppConnection() {
55+
override fun handleIq(iq: IQ): IQ? {
56+
if (iq is ConferenceModifyIQ) {
57+
colibriRequests.add(iq)
58+
return colibri2Server.handleConferenceModifyIq(iq)
59+
}
60+
return null
61+
}
62+
}
63+
64+
private val bridge: Bridge = mockk(relaxed = true) {
65+
every { jid } returns JidCreate.from("jvb@example.com/jvb1")
66+
every { relayId } returns null
67+
every { isOperational } returns true
68+
every { debugState } returns OrderedJsonObject()
69+
every { region } returns "us-east"
70+
}
71+
72+
private val bridgeSelector: BridgeSelector = mockk {
73+
every { selectBridge(any(), any(), any()) } returns bridge
74+
}
75+
76+
private fun createSessionManager() = ColibriV2SessionManager(
77+
xmppConnection.xmppConnection,
78+
bridgeSelector,
79+
"test-conference",
80+
"test-meeting-id",
81+
false,
82+
null,
83+
createLogger()
84+
)
85+
86+
private fun allocateParticipant(manager: ColibriV2SessionManager, id: String = "p1") = manager.allocate(
87+
ParticipantAllocationParameters(
88+
id = id,
89+
statsId = null,
90+
region = null,
91+
sources = EndpointSourceSet.EMPTY,
92+
useSsrcRewriting = false,
93+
forceMuteAudio = false,
94+
forceMuteVideo = false,
95+
useSctp = false,
96+
visitor = false,
97+
supportsPrivateAddresses = false,
98+
medias = emptySet()
99+
)
100+
)
101+
102+
override suspend fun beforeAny(testCase: TestCase) = super.beforeAny(testCase).also {
103+
TaskPools.ioPool = inPlaceExecutor
104+
TaskPools.scheduledPool = inPlaceScheduledExecutor
105+
}
106+
107+
override suspend fun afterAny(testCase: TestCase, result: TestResult) = super.afterAny(testCase, result).also {
108+
TaskPools.resetIoPool()
109+
TaskPools.resetScheduledPool()
110+
}
111+
112+
init {
113+
// bridge.region = "us-east", so the URL becomes wss://us-east.transcriber.example.com/rec
114+
val transcriberUrl = TemplatedUrl(
115+
"wss://{{REGION}}.transcriber.example.com/rec",
116+
requiredKeys = setOf("REGION")
117+
)
118+
val customHeaders = mapOf("X-Custom-Header" to "custom-value", "Authorization" to "Bearer custom")
119+
val urlParams = mapOf("key1" to "value1", "key2" to "hello world")
120+
val expectedBaseUrl = "wss://us-east.transcriber.example.com/rec"
121+
122+
context("Transcription configured before session exists") {
123+
// This covers the bug fix: when setTranscriberUrl is called before any Colibri session exists,
124+
// the custom headers and URL params must be included in the initial create request
125+
// (via sendAllocationRequest → createRequest(create=true)).
126+
val manager = createSessionManager()
127+
manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams)
128+
allocateParticipant(manager)
129+
130+
val createRequest = colibriRequests.find { it.create }
131+
createRequest shouldNotBe null
132+
val connect = createRequest!!.connects?.getConnects()?.firstOrNull()
133+
134+
should("include a Connect in the create request") {
135+
connect shouldNotBe null
136+
}
137+
should("use custom HTTP headers") {
138+
val headers = connect!!.getHttpHeaders().associate { it.name to it.value }
139+
headers["X-Custom-Header"] shouldBe "custom-value"
140+
headers["Authorization"] shouldBe "Bearer custom"
141+
}
142+
should("append URL params to the transcriber URL") {
143+
val urlStr = connect!!.url.toString()
144+
urlStr shouldContain "$expectedBaseUrl?"
145+
urlStr shouldContain "key1=value1"
146+
urlStr shouldContain "key2=hello+world"
147+
}
148+
}
149+
150+
context("Transcription configured after session exists") {
151+
// Verifies the working path: setTranscriberUrl called after a session exists sends the
152+
// custom headers and URL params in the update request.
153+
val manager = createSessionManager()
154+
allocateParticipant(manager)
155+
colibriRequests.clear()
156+
manager.setTranscriberUrl(transcriberUrl, customHeaders, urlParams)
157+
158+
val setUrlRequest = colibriRequests.firstOrNull()
159+
setUrlRequest shouldNotBe null
160+
val connect = setUrlRequest!!.connects?.getConnects()?.firstOrNull()
161+
162+
should("include a Connect in the setTranscriberUrl request") {
163+
connect shouldNotBe null
164+
}
165+
should("use custom HTTP headers") {
166+
val headers = connect!!.getHttpHeaders().associate { it.name to it.value }
167+
headers["X-Custom-Header"] shouldBe "custom-value"
168+
headers["Authorization"] shouldBe "Bearer custom"
169+
}
170+
should("append URL params to the transcriber URL") {
171+
val urlStr = connect!!.url.toString()
172+
urlStr shouldContain "$expectedBaseUrl?"
173+
urlStr shouldContain "key1=value1"
174+
urlStr shouldContain "key2=hello+world"
175+
}
176+
}
177+
178+
context("Transcription with no custom headers or params") {
179+
context("Configured before session exists") {
180+
val manager = createSessionManager()
181+
manager.setTranscriberUrl(transcriberUrl, null, null)
182+
allocateParticipant(manager)
183+
184+
val createRequest = colibriRequests.find { it.create }!!
185+
val connect = createRequest.connects?.getConnects()?.firstOrNull()
186+
187+
should("include a Connect") { connect shouldNotBe null }
188+
should("use the base URL without query params") {
189+
connect!!.url.toString() shouldBe expectedBaseUrl
190+
}
191+
should("include no HTTP headers") {
192+
connect!!.getHttpHeaders() shouldBe emptyList()
193+
}
194+
}
195+
context("Configured after session exists") {
196+
val manager = createSessionManager()
197+
allocateParticipant(manager)
198+
colibriRequests.clear()
199+
manager.setTranscriberUrl(transcriberUrl, null, null)
200+
201+
val setUrlRequest = colibriRequests.firstOrNull()!!
202+
val connect = setUrlRequest.connects?.getConnects()?.firstOrNull()
203+
204+
should("include a Connect") { connect shouldNotBe null }
205+
should("use the base URL without query params") {
206+
connect!!.url.toString() shouldBe expectedBaseUrl
207+
}
208+
should("include no HTTP headers") {
209+
connect!!.getHttpHeaders() shouldBe emptyList()
210+
}
211+
}
212+
}
213+
}
214+
}

0 commit comments

Comments
 (0)