@@ -34,6 +34,10 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
3434 endpoint_url(),
3535 bearer_token(),
3636 resource_url(),
37+ ice_gathering_mutex(),
38+ ice_gathering_cv(),
39+ ice_gathering_complete(false ),
40+ has_ice_servers(false ),
3741 running(false ),
3842 start_stop_mutex(),
3943 start_stop_thread(),
@@ -266,6 +270,67 @@ bool WHIPOutput::Init()
266270 return true ;
267271}
268272
273+ /* *
274+ * @brief Fetch ICE servers via OPTIONS request to WHIP endpoint.
275+ *
276+ * Per WHIP spec, the endpoint may provide STUN/TURN servers via Link headers
277+ * in response to an OPTIONS request. This allows ICE gathering to begin
278+ * before the offer is sent, enabling P2P connections behind NAT.
279+ *
280+ * @param iceServers Vector to populate with discovered ICE servers
281+ * @return bool True if request succeeded (even if no ICE servers found)
282+ */
283+ bool WHIPOutput::FetchIceServersViaOptions (std::vector<rtc::IceServer> &iceServers)
284+ {
285+ struct curl_slist *headers = nullptr ;
286+ headers = curl_slist_append (headers, " Accept: application/sdp" );
287+ headers = curl_slist_append (headers, user_agent.c_str ());
288+
289+ if (!bearer_token.empty ()) {
290+ auto bearer_token_header = std::string (" Authorization: Bearer " ) + bearer_token;
291+ headers = curl_slist_append (headers, bearer_token_header.c_str ());
292+ }
293+
294+ std::vector<std::string> http_headers;
295+
296+ CURL *c = curl_easy_init ();
297+ curl_easy_setopt (c, CURLOPT_HTTPHEADER, headers);
298+ curl_easy_setopt (c, CURLOPT_URL, endpoint_url.c_str ());
299+ curl_easy_setopt (c, CURLOPT_CUSTOMREQUEST, " OPTIONS" );
300+ curl_easy_setopt (c, CURLOPT_NOBODY, 1L );
301+ curl_easy_setopt (c, CURLOPT_TIMEOUT, 5L );
302+ curl_easy_setopt (c, CURLOPT_HEADERFUNCTION, curl_header_function);
303+ curl_easy_setopt (c, CURLOPT_HEADERDATA, (void *)&http_headers);
304+
305+ CURLcode res = curl_easy_perform (c);
306+ curl_easy_cleanup (c);
307+ curl_slist_free_all (headers);
308+
309+ if (res != CURLE_OK) {
310+ do_log (LOG_DEBUG, " OPTIONS request failed: %s (will proceed without pre-configured ICE servers)" ,
311+ curl_easy_strerror (res));
312+ return false ;
313+ }
314+
315+ for (auto &http_header : http_headers) {
316+ auto value = value_for_header (" link" , http_header);
317+ if (value.empty ())
318+ continue ;
319+
320+ for (auto end = value.find (" ," ); end != std::string::npos; end = value.find (" ," )) {
321+ this ->ParseLinkHeader (value.substr (0 , end), iceServers);
322+ value = value.substr (end + 1 );
323+ }
324+ this ->ParseLinkHeader (value, iceServers);
325+ }
326+
327+ if (!iceServers.empty ()) {
328+ do_log (LOG_INFO, " Discovered %zu ICE server(s) via OPTIONS request" , iceServers.size ());
329+ }
330+
331+ return true ;
332+ }
333+
269334/* *
270335 * @brief Set up the PeerConnection and media tracks.
271336 *
@@ -275,12 +340,31 @@ bool WHIPOutput::Setup()
275340{
276341 rtc::Configuration cfg;
277342
343+ // Fetch ICE servers via OPTIONS request (per WHIP spec section 4.4)
344+ std::vector<rtc::IceServer> iceServers;
345+ FetchIceServersViaOptions (iceServers);
346+ has_ice_servers = !iceServers.empty ();
347+ if (has_ice_servers) {
348+ cfg.iceServers = iceServers;
349+ }
350+
278351#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
279- cfg.disableAutoGathering = true ;
352+ // Enable auto-gathering if we have ICE servers from OPTIONS
353+ cfg.disableAutoGathering = iceServers.empty ();
280354#endif
281355
356+ ice_gathering_complete = false ;
282357 peer_connection = std::make_shared<rtc::PeerConnection>(cfg);
283358
359+ // Set up async ICE gathering completion notification
360+ peer_connection->onGatheringStateChange ([this ](rtc::PeerConnection::GatheringState state) {
361+ if (state == rtc::PeerConnection::GatheringState::Complete) {
362+ std::lock_guard<std::mutex> lock (ice_gathering_mutex);
363+ ice_gathering_complete = true ;
364+ ice_gathering_cv.notify_one ();
365+ }
366+ });
367+
284368 peer_connection->onStateChange ([this ](rtc::PeerConnection::State state) {
285369 switch (state) {
286370 case rtc::PeerConnection::State::New:
@@ -400,6 +484,22 @@ bool WHIPOutput::Connect()
400484 std::string read_buffer;
401485 std::vector<std::string> http_headers;
402486
487+ #if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
488+ // Wait for ICE gathering to complete (with timeout) so candidates are
489+ // bundled in the offer. This is required for P2P WHIP endpoints that
490+ // may not support trickle ICE. Only wait if we have ICE servers.
491+ if (has_ice_servers) {
492+ std::unique_lock<std::mutex> lock (ice_gathering_mutex);
493+ if (!ice_gathering_complete) {
494+ auto timeout = std::chrono::milliseconds (5000 );
495+ if (!ice_gathering_cv.wait_for (lock, timeout,
496+ [this ] { return ice_gathering_complete.load (); })) {
497+ do_log (LOG_WARNING, " ICE gathering timed out; sending offer with partial candidates" );
498+ }
499+ }
500+ }
501+ #endif
502+
403503 auto offer_sdp = std::string (peer_connection->localDescription ().value ());
404504
405505#ifdef DEBUG_SDP
@@ -574,6 +674,9 @@ bool WHIPOutput::Connect()
574674 doCleanup (false );
575675
576676#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
677+ // Always gather with POST response servers to:
678+ // 1. Get host candidates even if no ICE servers provided
679+ // 2. Incorporate any TURN servers/credentials from the POST response
577680 peer_connection->gatherLocalCandidates (iceServers);
578681#endif
579682
0 commit comments