MediaInfoHelper.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. using System;
  2. using System.Globalization;
  3. using System.Linq;
  4. using System.Net;
  5. using System.Text.Json;
  6. using System.Threading;
  7. using System.Threading.Tasks;
  8. using Jellyfin.Data.Entities;
  9. using Jellyfin.Data.Enums;
  10. using MediaBrowser.Common.Extensions;
  11. using MediaBrowser.Common.Net;
  12. using MediaBrowser.Controller.Configuration;
  13. using MediaBrowser.Controller.Devices;
  14. using MediaBrowser.Controller.Entities;
  15. using MediaBrowser.Controller.Entities.Audio;
  16. using MediaBrowser.Controller.Library;
  17. using MediaBrowser.Controller.MediaEncoding;
  18. using MediaBrowser.Controller.Net;
  19. using MediaBrowser.Model.Dlna;
  20. using MediaBrowser.Model.Dto;
  21. using MediaBrowser.Model.Entities;
  22. using MediaBrowser.Model.MediaInfo;
  23. using MediaBrowser.Model.Session;
  24. using Microsoft.AspNetCore.Http;
  25. using Microsoft.Extensions.Logging;
  26. namespace Jellyfin.Api.Helpers
  27. {
  28. /// <summary>
  29. /// Media info helper.
  30. /// </summary>
  31. public class MediaInfoHelper
  32. {
  33. private readonly IUserManager _userManager;
  34. private readonly ILibraryManager _libraryManager;
  35. private readonly IMediaSourceManager _mediaSourceManager;
  36. private readonly IMediaEncoder _mediaEncoder;
  37. private readonly IServerConfigurationManager _serverConfigurationManager;
  38. private readonly ILogger<MediaInfoHelper> _logger;
  39. private readonly INetworkManager _networkManager;
  40. private readonly IDeviceManager _deviceManager;
  41. private readonly IAuthorizationContext _authContext;
  42. /// <summary>
  43. /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
  44. /// </summary>
  45. /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
  46. /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
  47. /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
  48. /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
  49. /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
  50. /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
  51. /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
  52. /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
  53. /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
  54. public MediaInfoHelper(
  55. IUserManager userManager,
  56. ILibraryManager libraryManager,
  57. IMediaSourceManager mediaSourceManager,
  58. IMediaEncoder mediaEncoder,
  59. IServerConfigurationManager serverConfigurationManager,
  60. ILogger<MediaInfoHelper> logger,
  61. INetworkManager networkManager,
  62. IDeviceManager deviceManager,
  63. IAuthorizationContext authContext)
  64. {
  65. _userManager = userManager;
  66. _libraryManager = libraryManager;
  67. _mediaSourceManager = mediaSourceManager;
  68. _mediaEncoder = mediaEncoder;
  69. _serverConfigurationManager = serverConfigurationManager;
  70. _logger = logger;
  71. _networkManager = networkManager;
  72. _deviceManager = deviceManager;
  73. _authContext = authContext;
  74. }
  75. /// <summary>
  76. /// Get playback info.
  77. /// </summary>
  78. /// <param name="id">Item id.</param>
  79. /// <param name="userId">User Id.</param>
  80. /// <param name="mediaSourceId">Media source id.</param>
  81. /// <param name="liveStreamId">Live stream id.</param>
  82. /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
  83. public async Task<PlaybackInfoResponse> GetPlaybackInfo(
  84. Guid id,
  85. Guid? userId,
  86. string? mediaSourceId = null,
  87. string? liveStreamId = null)
  88. {
  89. var user = userId is null || userId.Value.Equals(default)
  90. ? null
  91. : _userManager.GetUserById(userId.Value);
  92. var item = _libraryManager.GetItemById(id);
  93. var result = new PlaybackInfoResponse();
  94. MediaSourceInfo[] mediaSources;
  95. if (string.IsNullOrWhiteSpace(liveStreamId))
  96. {
  97. // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
  98. var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
  99. if (string.IsNullOrWhiteSpace(mediaSourceId))
  100. {
  101. mediaSources = mediaSourcesList.ToArray();
  102. }
  103. else
  104. {
  105. mediaSources = mediaSourcesList
  106. .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
  107. .ToArray();
  108. }
  109. }
  110. else
  111. {
  112. var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
  113. mediaSources = new[] { mediaSource };
  114. }
  115. if (mediaSources.Length == 0)
  116. {
  117. result.MediaSources = Array.Empty<MediaSourceInfo>();
  118. result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
  119. }
  120. else
  121. {
  122. // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
  123. // Should we move this directly into MediaSourceManager?
  124. var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
  125. if (mediaSourcesClone != null)
  126. {
  127. result.MediaSources = mediaSourcesClone;
  128. }
  129. result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
  130. }
  131. return result;
  132. }
  133. /// <summary>
  134. /// SetDeviceSpecificData.
  135. /// </summary>
  136. /// <param name="item">Item to set data for.</param>
  137. /// <param name="mediaSource">Media source info.</param>
  138. /// <param name="profile">Device profile.</param>
  139. /// <param name="auth">Authorization info.</param>
  140. /// <param name="maxBitrate">Max bitrate.</param>
  141. /// <param name="startTimeTicks">Start time ticks.</param>
  142. /// <param name="mediaSourceId">Media source id.</param>
  143. /// <param name="audioStreamIndex">Audio stream index.</param>
  144. /// <param name="subtitleStreamIndex">Subtitle stream index.</param>
  145. /// <param name="maxAudioChannels">Max audio channels.</param>
  146. /// <param name="playSessionId">Play session id.</param>
  147. /// <param name="userId">User id.</param>
  148. /// <param name="enableDirectPlay">Enable direct play.</param>
  149. /// <param name="enableDirectStream">Enable direct stream.</param>
  150. /// <param name="enableTranscoding">Enable transcoding.</param>
  151. /// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
  152. /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
  153. /// <param name="ipAddress">Requesting IP address.</param>
  154. public void SetDeviceSpecificData(
  155. BaseItem item,
  156. MediaSourceInfo mediaSource,
  157. DeviceProfile profile,
  158. AuthorizationInfo auth,
  159. int? maxBitrate,
  160. long startTimeTicks,
  161. string mediaSourceId,
  162. int? audioStreamIndex,
  163. int? subtitleStreamIndex,
  164. int? maxAudioChannels,
  165. string playSessionId,
  166. Guid userId,
  167. bool enableDirectPlay,
  168. bool enableDirectStream,
  169. bool enableTranscoding,
  170. bool allowVideoStreamCopy,
  171. bool allowAudioStreamCopy,
  172. IPAddress ipAddress)
  173. {
  174. var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
  175. var options = new VideoOptions
  176. {
  177. MediaSources = new[] { mediaSource },
  178. Context = EncodingContext.Streaming,
  179. DeviceId = auth.DeviceId,
  180. ItemId = item.Id,
  181. Profile = profile,
  182. MaxAudioChannels = maxAudioChannels
  183. };
  184. if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
  185. {
  186. options.MediaSourceId = mediaSourceId;
  187. options.AudioStreamIndex = audioStreamIndex;
  188. options.SubtitleStreamIndex = subtitleStreamIndex;
  189. }
  190. var user = _userManager.GetUserById(userId);
  191. if (!enableDirectPlay)
  192. {
  193. mediaSource.SupportsDirectPlay = false;
  194. }
  195. if (!enableDirectStream)
  196. {
  197. mediaSource.SupportsDirectStream = false;
  198. }
  199. if (!enableTranscoding)
  200. {
  201. mediaSource.SupportsTranscoding = false;
  202. }
  203. if (item is Audio)
  204. {
  205. _logger.LogInformation(
  206. "User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
  207. user.Username,
  208. user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
  209. }
  210. else
  211. {
  212. _logger.LogInformation(
  213. "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
  214. user.Username,
  215. user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
  216. user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
  217. user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
  218. }
  219. // Beginning of Playback Determination: Attempt DirectPlay first
  220. if (mediaSource.SupportsDirectPlay)
  221. {
  222. if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
  223. {
  224. mediaSource.SupportsDirectPlay = false;
  225. }
  226. else
  227. {
  228. var supportsDirectStream = mediaSource.SupportsDirectStream;
  229. // Dummy this up to fool StreamBuilder
  230. mediaSource.SupportsDirectStream = true;
  231. options.MaxBitrate = maxBitrate;
  232. if (item is Audio)
  233. {
  234. if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
  235. {
  236. options.ForceDirectPlay = true;
  237. }
  238. }
  239. else if (item is Video)
  240. {
  241. if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
  242. && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
  243. && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
  244. {
  245. options.ForceDirectPlay = true;
  246. }
  247. }
  248. // The MediaSource supports direct stream, now test to see if the client supports it
  249. var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
  250. ? streamBuilder.BuildAudioItem(options)
  251. : streamBuilder.BuildVideoItem(options);
  252. if (streamInfo == null || !streamInfo.IsDirectStream)
  253. {
  254. mediaSource.SupportsDirectPlay = false;
  255. }
  256. // Set this back to what it was
  257. mediaSource.SupportsDirectStream = supportsDirectStream;
  258. if (streamInfo != null)
  259. {
  260. SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
  261. mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
  262. }
  263. }
  264. }
  265. if (mediaSource.SupportsDirectStream)
  266. {
  267. if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
  268. {
  269. mediaSource.SupportsDirectStream = false;
  270. }
  271. else
  272. {
  273. options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
  274. if (item is Audio)
  275. {
  276. if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
  277. {
  278. options.ForceDirectStream = true;
  279. }
  280. }
  281. else if (item is Video)
  282. {
  283. if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
  284. && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
  285. && user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
  286. {
  287. options.ForceDirectStream = true;
  288. }
  289. }
  290. // The MediaSource supports direct stream, now test to see if the client supports it
  291. var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
  292. ? streamBuilder.BuildAudioItem(options)
  293. : streamBuilder.BuildVideoItem(options);
  294. if (streamInfo == null || !streamInfo.IsDirectStream)
  295. {
  296. mediaSource.SupportsDirectStream = false;
  297. }
  298. if (streamInfo != null)
  299. {
  300. SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
  301. mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
  302. }
  303. }
  304. }
  305. if (mediaSource.SupportsTranscoding)
  306. {
  307. options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
  308. // The MediaSource supports direct stream, now test to see if the client supports it
  309. var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
  310. ? streamBuilder.BuildAudioItem(options)
  311. : streamBuilder.BuildVideoItem(options);
  312. if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
  313. {
  314. if (streamInfo != null)
  315. {
  316. streamInfo.PlaySessionId = playSessionId;
  317. streamInfo.StartPositionTicks = startTimeTicks;
  318. mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
  319. mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
  320. mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
  321. mediaSource.TranscodingContainer = streamInfo.Container;
  322. mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
  323. // Do this after the above so that StartPositionTicks is set
  324. SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
  325. mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
  326. }
  327. }
  328. else
  329. {
  330. if (streamInfo != null)
  331. {
  332. streamInfo.PlaySessionId = playSessionId;
  333. if (streamInfo.PlayMethod == PlayMethod.Transcode)
  334. {
  335. streamInfo.StartPositionTicks = startTimeTicks;
  336. mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
  337. if (!allowVideoStreamCopy)
  338. {
  339. mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
  340. }
  341. if (!allowAudioStreamCopy)
  342. {
  343. mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
  344. }
  345. mediaSource.TranscodingContainer = streamInfo.Container;
  346. mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
  347. }
  348. if (!allowAudioStreamCopy)
  349. {
  350. mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
  351. }
  352. mediaSource.TranscodingContainer = streamInfo.Container;
  353. mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
  354. // Do this after the above so that StartPositionTicks is set
  355. SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
  356. mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
  357. }
  358. }
  359. }
  360. foreach (var attachment in mediaSource.MediaAttachments)
  361. {
  362. attachment.DeliveryUrl = string.Format(
  363. CultureInfo.InvariantCulture,
  364. "/Videos/{0}/{1}/Attachments/{2}",
  365. item.Id,
  366. mediaSource.Id,
  367. attachment.Index);
  368. }
  369. }
  370. /// <summary>
  371. /// Sort media source.
  372. /// </summary>
  373. /// <param name="result">Playback info response.</param>
  374. /// <param name="maxBitrate">Max bitrate.</param>
  375. public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
  376. {
  377. var originalList = result.MediaSources.ToList();
  378. result.MediaSources = result.MediaSources.OrderBy(i =>
  379. {
  380. // Nothing beats direct playing a file
  381. if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
  382. {
  383. return 0;
  384. }
  385. return 1;
  386. })
  387. .ThenBy(i =>
  388. {
  389. // Let's assume direct streaming a file is just as desirable as direct playing a remote url
  390. if (i.SupportsDirectPlay || i.SupportsDirectStream)
  391. {
  392. return 0;
  393. }
  394. return 1;
  395. })
  396. .ThenBy(i =>
  397. {
  398. return i.Protocol switch
  399. {
  400. MediaProtocol.File => 0,
  401. _ => 1,
  402. };
  403. })
  404. .ThenBy(i =>
  405. {
  406. if (maxBitrate.HasValue && i.Bitrate.HasValue)
  407. {
  408. return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
  409. }
  410. return 1;
  411. })
  412. .ThenBy(originalList.IndexOf)
  413. .ToArray();
  414. }
  415. /// <summary>
  416. /// Open media source.
  417. /// </summary>
  418. /// <param name="httpRequest">Http Request.</param>
  419. /// <param name="request">Live stream request.</param>
  420. /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
  421. public async Task<LiveStreamResponse> OpenMediaSource(HttpRequest httpRequest, LiveStreamRequest request)
  422. {
  423. var authInfo = await _authContext.GetAuthorizationInfo(httpRequest).ConfigureAwait(false);
  424. var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
  425. var profile = request.DeviceProfile;
  426. if (profile == null)
  427. {
  428. var clientCapabilities = _deviceManager.GetCapabilities(authInfo.DeviceId);
  429. if (clientCapabilities != null)
  430. {
  431. profile = clientCapabilities.DeviceProfile;
  432. }
  433. }
  434. if (profile != null)
  435. {
  436. var item = _libraryManager.GetItemById(request.ItemId);
  437. SetDeviceSpecificData(
  438. item,
  439. result.MediaSource,
  440. profile,
  441. authInfo,
  442. request.MaxStreamingBitrate,
  443. request.StartTimeTicks ?? 0,
  444. result.MediaSource.Id,
  445. request.AudioStreamIndex,
  446. request.SubtitleStreamIndex,
  447. request.MaxAudioChannels,
  448. request.PlaySessionId,
  449. request.UserId,
  450. request.EnableDirectPlay,
  451. request.EnableDirectStream,
  452. true,
  453. true,
  454. true,
  455. httpRequest.HttpContext.GetNormalizedRemoteIp());
  456. }
  457. else
  458. {
  459. if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
  460. {
  461. result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
  462. }
  463. }
  464. // here was a check if (result.MediaSource != null) but Rider said it will never be null
  465. NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
  466. return result;
  467. }
  468. /// <summary>
  469. /// Normalize media source container.
  470. /// </summary>
  471. /// <param name="mediaSource">Media source.</param>
  472. /// <param name="profile">Device profile.</param>
  473. /// <param name="type">Dlna profile type.</param>
  474. public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
  475. {
  476. mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type);
  477. }
  478. private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
  479. {
  480. var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
  481. mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
  482. mediaSource.TranscodeReasons = info.TranscodeReasons;
  483. foreach (var profile in profiles)
  484. {
  485. foreach (var stream in mediaSource.MediaStreams)
  486. {
  487. if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
  488. {
  489. stream.DeliveryMethod = profile.DeliveryMethod;
  490. if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
  491. {
  492. stream.DeliveryUrl = profile.Url.TrimStart('-');
  493. stream.IsExternalUrl = profile.IsExternalUrl;
  494. }
  495. }
  496. }
  497. }
  498. }
  499. private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress)
  500. {
  501. var maxBitrate = clientMaxBitrate;
  502. var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
  503. if (remoteClientMaxBitrate <= 0)
  504. {
  505. remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
  506. }
  507. if (remoteClientMaxBitrate > 0)
  508. {
  509. var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress);
  510. _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork);
  511. if (!isInLocalNetwork)
  512. {
  513. maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
  514. }
  515. }
  516. return maxBitrate;
  517. }
  518. }
  519. }