#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library { public class MediaSourceManager : IMediaSourceManager, IDisposable { // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. private const char LiveStreamIdDelimeter = '_'; private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; private readonly IFileSystem _fileSystem; private readonly ILogger _logger; private readonly IUserDataManager _userDataManager; private readonly IMediaEncoder _mediaEncoder; private readonly ILocalizationManager _localizationManager; private readonly IApplicationPaths _appPaths; private readonly IDirectoryService _directoryService; private readonly ConcurrentDictionary _openStreams = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _liveStreamSemaphore = new SemaphoreSlim(1, 1); private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private IMediaSourceProvider[] _providers; public MediaSourceManager( IItemRepository itemRepo, IApplicationPaths applicationPaths, ILocalizationManager localizationManager, IUserManager userManager, ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem, IUserDataManager userDataManager, IMediaEncoder mediaEncoder, IDirectoryService directoryService) { _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; _logger = logger; _fileSystem = fileSystem; _userDataManager = userDataManager; _mediaEncoder = mediaEncoder; _localizationManager = localizationManager; _appPaths = applicationPaths; _directoryService = directoryService; } public void AddParts(IEnumerable providers) { _providers = providers.ToArray(); } public List GetMediaStreams(MediaStreamQuery query) { var list = _itemRepo.GetMediaStreams(query); foreach (var stream in list) { stream.SupportsExternalStream = StreamSupportsExternalStream(stream); } return list; } private static bool StreamSupportsExternalStream(MediaStream stream) { if (stream.IsExternal) { return true; } if (stream.IsTextSubtitleStream) { return true; } return false; } public List GetMediaStreams(Guid itemId) { var list = GetMediaStreams(new MediaStreamQuery { ItemId = itemId }); return GetMediaStreamsForItem(list); } private List GetMediaStreamsForItem(List streams) { foreach (var stream in streams) { if (stream.Type == MediaStreamType.Subtitle) { stream.SupportsExternalStream = StreamSupportsExternalStream(stream); } } return streams; } /// public List GetMediaAttachments(MediaAttachmentQuery query) { return _itemRepo.GetMediaAttachments(query); } /// public List GetMediaAttachments(Guid itemId) { return GetMediaAttachments(new MediaAttachmentQuery { ItemId = itemId }); } public async Task> GetPlaybackMediaSources(BaseItem item, User user, bool allowMediaProbe, bool enablePathSubstitution, CancellationToken cancellationToken) { var mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder && (item.Path.EndsWith(".strm") || (item.MediaType == MediaType.Video && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Video)) || (item.MediaType == MediaType.Audio && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Audio)))) { await item.RefreshMetadata( new MetadataRefreshOptions(_directoryService) { EnableRemoteContentProbe = true, MetadataRefreshMode = MetadataRefreshMode.FullRefresh }, cancellationToken).ConfigureAwait(false); mediaSources = GetStaticMediaSources(item, enablePathSubstitution, user); } var dynamicMediaSources = await GetDynamicMediaSources(item, cancellationToken).ConfigureAwait(false); var list = new List(); list.AddRange(mediaSources); foreach (var source in dynamicMediaSources) { // Validate that this is actually possible if (source.SupportsDirectStream) { source.SupportsDirectStream = SupportsDirectStream(source.Path, source.Protocol); } if (user != null) { SetDefaultAudioAndSubtitleStreamIndexes(item, source, user); if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding); } else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding); source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } list.Add(source); } return SortMediaSources(list); } /// > public MediaProtocol GetPathProtocol(string path) { if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtsp; } if (path.StartsWith("Rtmp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtmp; } if (path.StartsWith("Http", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Http; } if (path.StartsWith("rtp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtp; } if (path.StartsWith("ftp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Ftp; } if (path.StartsWith("udp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Udp; } return _fileSystem.IsPathFile(path) ? MediaProtocol.File : MediaProtocol.Http; } public bool SupportsDirectStream(string path, MediaProtocol protocol) { if (protocol == MediaProtocol.File) { return true; } if (protocol == MediaProtocol.Http) { if (path != null) { if (path.Contains(".m3u", StringComparison.OrdinalIgnoreCase)) { return false; } return true; } } return false; } private async Task> GetDynamicMediaSources(BaseItem item, CancellationToken cancellationToken) { var tasks = _providers.Select(i => GetDynamicMediaSources(item, i, cancellationToken)); var results = await Task.WhenAll(tasks).ConfigureAwait(false); return results.SelectMany(i => i.ToList()); } private async Task> GetDynamicMediaSources(BaseItem item, IMediaSourceProvider provider, CancellationToken cancellationToken) { try { var sources = await provider.GetMediaSources(item, cancellationToken).ConfigureAwait(false); var list = sources.ToList(); foreach (var mediaSource in list) { mediaSource.InferTotalBitrate(); SetKeyProperties(provider, mediaSource); } return list; } catch (Exception ex) { _logger.LogError(ex, "Error getting media sources"); return Enumerable.Empty(); } } private static void SetKeyProperties(IMediaSourceProvider provider, MediaSourceInfo mediaSource) { var prefix = provider.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + LiveStreamIdDelimeter; if (!string.IsNullOrEmpty(mediaSource.OpenToken) && !mediaSource.OpenToken.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { mediaSource.OpenToken = prefix + mediaSource.OpenToken; } if (!string.IsNullOrEmpty(mediaSource.LiveStreamId) && !mediaSource.LiveStreamId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { mediaSource.LiveStreamId = prefix + mediaSource.LiveStreamId; } } public async Task GetMediaSource(BaseItem item, string mediaSourceId, string liveStreamId, bool enablePathSubstitution, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(liveStreamId)) { return await GetLiveStream(liveStreamId, cancellationToken).ConfigureAwait(false); } var sources = await GetPlaybackMediaSources(item, null, false, enablePathSubstitution, cancellationToken).ConfigureAwait(false); return sources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)); } public List GetStaticMediaSources(BaseItem item, bool enablePathSubstitution, User user = null) { if (item == null) { throw new ArgumentNullException(nameof(item)); } var hasMediaSources = (IHasMediaSources)item; var sources = hasMediaSources.GetMediaSources(enablePathSubstitution); if (user != null) { foreach (var source in sources) { SetDefaultAudioAndSubtitleStreamIndexes(item, source, user); if (string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding); } else if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) { source.SupportsTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding); source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing); } } } return sources; } private IReadOnlyList NormalizeLanguage(string language) { if (string.IsNullOrEmpty(language)) { return Array.Empty(); } var culture = _localizationManager.FindLanguageInfo(language); if (culture != null) { return culture.ThreeLetterISOLanguageNames; } return new string[] { language }; } private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) { if (userData.SubtitleStreamIndex.HasValue && user.RememberSubtitleSelections && user.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection) { var index = userData.SubtitleStreamIndex.Value; // Make sure the saved index is still valid if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index)) { source.DefaultSubtitleStreamIndex = index; return; } } var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference); var defaultAudioIndex = source.DefaultAudioStreamIndex; var audioLangage = defaultAudioIndex == null ? null : source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault(); source.DefaultSubtitleStreamIndex = MediaStreamSelector.GetDefaultSubtitleStreamIndex( source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage); MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage); } private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection) { if (userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection) { var index = userData.AudioStreamIndex.Value; // Make sure the saved index is still valid if (source.MediaStreams.Any(i => i.Type == MediaStreamType.Audio && i.Index == index)) { source.DefaultAudioStreamIndex = index; return; } } var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference); source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack); } public void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user) { // Item would only be null if the app didn't supply ItemId as part of the live stream open request var mediaType = item == null ? MediaType.Video : item.MediaType; if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase)) { var userData = item == null ? new UserItemData() : _userDataManager.GetUserData(user, item); var allowRememberingSelection = item == null || item.EnableRememberingTrackSelections; SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection); SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection); } else if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)) { var audio = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); if (audio != null) { source.DefaultAudioStreamIndex = audio.Index; } } } private static List SortMediaSources(IEnumerable sources) { return sources.OrderBy(i => { if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) { return 0; } return 1; }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) .ThenByDescending(i => { var stream = i.VideoStream; return stream?.Width ?? 0; }) .Where(i => i.Type != MediaSourceType.Placeholder) .ToList(); } public async Task> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) { await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); MediaSourceInfo mediaSource; ILiveStream liveStream; try { var (provider, keyId) = GetProvider(request.OpenToken); var currentLiveStreams = _openStreams.Values.ToList(); liveStream = await provider.OpenMediaSource(keyId, currentLiveStreams, cancellationToken).ConfigureAwait(false); mediaSource = liveStream.MediaSource; // Validate that this is actually possible if (mediaSource.SupportsDirectStream) { mediaSource.SupportsDirectStream = SupportsDirectStream(mediaSource.Path, mediaSource.Protocol); } SetKeyProperties(provider, mediaSource); _openStreams[mediaSource.LiveStreamId] = liveStream; } finally { _liveStreamSemaphore.Release(); } try { if (mediaSource.MediaStreams.Any(i => i.Index != -1) || !mediaSource.SupportsProbing) { AddMediaInfo(mediaSource); } else { // hack - these two values were taken from LiveTVMediaSourceProvider string cacheKey = request.OpenToken; await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) .AddMediaInfoWithProbe(mediaSource, false, cacheKey, true, cancellationToken) .ConfigureAwait(false); } } catch (Exception ex) { _logger.LogError(ex, "Error probing live tv stream"); AddMediaInfo(mediaSource); } // TODO: @bond Fix var json = JsonSerializer.SerializeToUtf8Bytes(mediaSource, _jsonOptions); _logger.LogInformation("Live stream opened: {@MediaSource}", mediaSource); var clone = JsonSerializer.Deserialize(json, _jsonOptions); if (!request.UserId.Equals(default)) { var user = _userManager.GetUserById(request.UserId); var item = request.ItemId.Equals(default) ? null : _libraryManager.GetItemById(request.ItemId); SetDefaultAudioAndSubtitleStreamIndexes(item, clone, user); } return new Tuple(new LiveStreamResponse(clone), liveStream as IDirectStreamProvider); } private static void AddMediaInfo(MediaSourceInfo mediaSource) { mediaSource.DefaultSubtitleStreamIndex = null; // Null this out so that it will be treated like a live stream if (mediaSource.IsInfiniteStream) { mediaSource.RunTimeTicks = null; } var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); if (audioStream == null || audioStream.Index == -1) { mediaSource.DefaultAudioStreamIndex = null; } else { mediaSource.DefaultAudioStreamIndex = audioStream.Index; } var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); if (videoStream != null) { if (!videoStream.BitRate.HasValue) { var width = videoStream.Width ?? 1920; if (width >= 3000) { videoStream.BitRate = 30000000; } else if (width >= 1900) { videoStream.BitRate = 20000000; } else if (width >= 1200) { videoStream.BitRate = 8000000; } else if (width >= 700) { videoStream.BitRate = 2000000; } } } // Try to estimate this mediaSource.InferTotalBitrate(); } public async Task OpenLiveStream(LiveStreamRequest request, CancellationToken cancellationToken) { var result = await OpenLiveStreamInternal(request, cancellationToken).ConfigureAwait(false); return result.Item1; } public async Task GetLiveStreamMediaInfo(string id, CancellationToken cancellationToken) { // TODO probably shouldn't throw here but it is kept for "backwards compatibility" var liveStreamInfo = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); var mediaSource = liveStreamInfo.MediaSource; if (liveStreamInfo is IDirectStreamProvider) { var info = await _mediaEncoder.GetMediaInfo( new MediaInfoRequest { MediaSource = mediaSource, ExtractChapters = false, MediaType = DlnaProfileType.Video }, cancellationToken).ConfigureAwait(false); mediaSource.MediaStreams = info.MediaStreams; mediaSource.Container = info.Container; mediaSource.Bitrate = info.Bitrate; } return mediaSource; } public async Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken) { var originalRuntime = mediaSource.RunTimeTicks; var now = DateTime.UtcNow; MediaInfo mediaInfo = null; var cacheFilePath = string.IsNullOrEmpty(cacheKey) ? null : Path.Combine(_appPaths.CachePath, "mediainfo", cacheKey.GetMD5().ToString("N", CultureInfo.InvariantCulture) + ".json"); if (!string.IsNullOrEmpty(cacheKey)) { try { await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); mediaInfo = await JsonSerializer.DeserializeAsync(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } catch (Exception ex) { _logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception."); } } if (mediaInfo == null) { if (addProbeDelay) { var delayMs = mediaSource.AnalyzeDurationMs ?? 0; delayMs = Math.Max(3000, delayMs); await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); } if (isLiveStream) { mediaSource.AnalyzeDurationMs = 3000; } mediaInfo = await _mediaEncoder.GetMediaInfo( new MediaInfoRequest { MediaSource = mediaSource, MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, ExtractChapters = false }, cancellationToken).ConfigureAwait(false); if (cacheFilePath != null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); await using FileStream createStream = File.Create(cacheFilePath); await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } } var mediaStreams = mediaInfo.MediaStreams; if (isLiveStream && !string.IsNullOrEmpty(cacheKey)) { var newList = new List(); newList.AddRange(mediaStreams.Where(i => i.Type == MediaStreamType.Video).Take(1)); newList.AddRange(mediaStreams.Where(i => i.Type == MediaStreamType.Audio).Take(1)); foreach (var stream in newList) { stream.Index = -1; stream.Language = null; } mediaStreams = newList; } _logger.LogInformation("Live tv media info probe took {0} seconds", (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture)); mediaSource.Bitrate = mediaInfo.Bitrate; mediaSource.Container = mediaInfo.Container; mediaSource.Formats = mediaInfo.Formats; mediaSource.MediaStreams = mediaStreams; mediaSource.RunTimeTicks = mediaInfo.RunTimeTicks; mediaSource.Size = mediaInfo.Size; mediaSource.Timestamp = mediaInfo.Timestamp; mediaSource.Video3DFormat = mediaInfo.Video3DFormat; mediaSource.VideoType = mediaInfo.VideoType; mediaSource.DefaultSubtitleStreamIndex = null; if (isLiveStream) { // Null this out so that it will be treated like a live stream if (!originalRuntime.HasValue) { mediaSource.RunTimeTicks = null; } } var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); if (audioStream == null || audioStream.Index == -1) { mediaSource.DefaultAudioStreamIndex = null; } else { mediaSource.DefaultAudioStreamIndex = audioStream.Index; } var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video); if (videoStream != null) { if (!videoStream.BitRate.HasValue) { var width = videoStream.Width ?? 1920; if (width >= 3000) { videoStream.BitRate = 30000000; } else if (width >= 1900) { videoStream.BitRate = 20000000; } else if (width >= 1200) { videoStream.BitRate = 8000000; } else if (width >= 700) { videoStream.BitRate = 2000000; } } // This is coming up false and preventing stream copy videoStream.IsAVC = null; } if (isLiveStream) { mediaSource.AnalyzeDurationMs = 3000; } // Try to estimate this mediaSource.InferTotalBitrate(true); } public Task> GetLiveStreamWithDirectStreamProvider(string id, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } // TODO probably shouldn't throw here but it is kept for "backwards compatibility" var info = GetLiveStreamInfo(id) ?? throw new ResourceNotFoundException(); return Task.FromResult(new Tuple(info.MediaSource, info as IDirectStreamProvider)); } public ILiveStream GetLiveStreamInfo(string id) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } if (_openStreams.TryGetValue(id, out ILiveStream info)) { return info; } return null; } /// public ILiveStream GetLiveStreamInfoByUniqueId(string uniqueId) { return _openStreams.Values.FirstOrDefault(stream => string.Equals(uniqueId, stream?.UniqueId, StringComparison.OrdinalIgnoreCase)); } public async Task GetLiveStream(string id, CancellationToken cancellationToken) { var result = await GetLiveStreamWithDirectStreamProvider(id, cancellationToken).ConfigureAwait(false); return result.Item1; } public async Task CloseLiveStream(string id) { if (string.IsNullOrEmpty(id)) { throw new ArgumentNullException(nameof(id)); } await _liveStreamSemaphore.WaitAsync().ConfigureAwait(false); try { if (_openStreams.TryGetValue(id, out ILiveStream liveStream)) { liveStream.ConsumerCount--; _logger.LogInformation("Live stream {0} consumer count is now {1}", liveStream.OriginalStreamId, liveStream.ConsumerCount); if (liveStream.ConsumerCount <= 0) { _openStreams.TryRemove(id, out _); _logger.LogInformation("Closing live stream {0}", id); await liveStream.Close().ConfigureAwait(false); _logger.LogInformation("Live stream {0} closed successfully", id); } } } finally { _liveStreamSemaphore.Release(); } } private (IMediaSourceProvider MediaSourceProvider, string KeyId) GetProvider(string key) { if (string.IsNullOrEmpty(key)) { throw new ArgumentException("Key can't be empty.", nameof(key)); } var keys = key.Split(LiveStreamIdDelimeter, 2); var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); var splitIndex = key.IndexOf(LiveStreamIdDelimeter, StringComparison.Ordinal); var keyId = key.Substring(splitIndex + 1); return (provider, keyId); } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool dispose) { if (dispose) { foreach (var key in _openStreams.Keys.ToList()) { CloseLiveStream(key).GetAwaiter().GetResult(); } _liveStreamSemaphore.Dispose(); } } } }