using System; using System.Globalization; using System.Linq; using System.Net; using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers; /// /// Media info helper. /// public class MediaInfoHelper { private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILogger _logger; private readonly INetworkManager _networkManager; private readonly IDeviceManager _deviceManager; private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public MediaInfoHelper( IUserManager userManager, ILibraryManager libraryManager, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerConfigurationManager serverConfigurationManager, ILogger logger, INetworkManager networkManager, IDeviceManager deviceManager, IServerApplicationHost appHost) { _userManager = userManager; _libraryManager = libraryManager; _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; _serverConfigurationManager = serverConfigurationManager; _logger = logger; _networkManager = networkManager; _deviceManager = deviceManager; _appHost = appHost; } /// /// Get playback info. /// /// The item. /// The user. /// The current . /// Media source id. /// Live stream id. /// A containing the . public async Task GetPlaybackInfo( BaseItem item, User? user, HttpRequest request, string? mediaSourceId = null, string? liveStreamId = null) { var result = new PlaybackInfoResponse(); var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false); if (mediaSources.Length == 0) { result.MediaSources = Array.Empty(); result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; } else { // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it // Should we move this directly into MediaSourceManager? var mediaSourcesClone = JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); if (mediaSourcesClone is not null) { // Carry over the default audio index source. // This field is not intended to be exposed to API clients, but it is used internally by the server for (int i = 0; i < mediaSourcesClone.Length && i < mediaSources.Length; i++) { mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource; } foreach (var mediaSource in mediaSourcesClone) { RewritePublishedLiveStreamPath(mediaSource, request); } result.MediaSources = mediaSourcesClone; } result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); } return result; } private async Task ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId) { if (!string.IsNullOrWhiteSpace(liveStreamId)) { var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); return new[] { mediaSource }; } // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(mediaSourceId)) { return mediaSourcesList.ToArray(); } return mediaSourcesList .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) .ToArray(); } /// /// SetDeviceSpecificData. /// /// Item to set data for. /// Media source info. /// Device profile. /// Current claims principal. /// Max bitrate. /// Start time ticks. /// Media source id. /// Audio stream index. /// Subtitle stream index. /// Max audio channels. /// Play session id. /// User id. /// Enable direct play. /// Enable direct stream. /// Enable transcoding. /// Allow video stream copy. /// Allow audio stream copy. /// Always burn-in subtitle when transcoding. /// Requesting IP address. public void SetDeviceSpecificData( BaseItem item, MediaSourceInfo mediaSource, DeviceProfile profile, ClaimsPrincipal claimsPrincipal, int? maxBitrate, long startTimeTicks, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, int? maxAudioChannels, string playSessionId, Guid userId, bool enableDirectPlay, bool enableDirectStream, bool enableTranscoding, bool allowVideoStreamCopy, bool allowAudioStreamCopy, bool alwaysBurnInSubtitleWhenTranscoding, IPAddress ipAddress) { var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); var options = new MediaOptions { MediaSources = new[] { mediaSource }, Context = EncodingContext.Streaming, DeviceId = claimsPrincipal.GetDeviceId(), ItemId = item.Id, Profile = profile, MaxAudioChannels = maxAudioChannels, AllowAudioStreamCopy = allowAudioStreamCopy, AllowVideoStreamCopy = allowVideoStreamCopy, AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding, }; if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) { options.MediaSourceId = mediaSourceId; options.AudioStreamIndex = audioStreamIndex; options.SubtitleStreamIndex = subtitleStreamIndex; } var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException(); if (!enableDirectPlay) { mediaSource.SupportsDirectPlay = false; } if (!enableDirectStream || !allowVideoStreamCopy) { mediaSource.SupportsDirectStream = false; } if (!enableTranscoding) { mediaSource.SupportsTranscoding = false; } if (item is Audio) { _logger.LogInformation( "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", user.Username, user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); } else { _logger.LogInformation( "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", user.Username, user.HasPermission(PermissionKind.EnablePlaybackRemuxing), user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); } options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); if (!options.ForceDirectStream) { // direct-stream http streaming is currently broken options.EnableDirectStream = false; } // Beginning of Playback Determination var streamInfo = item.MediaType == MediaType.Audio ? streamBuilder.GetOptimalAudioStream(options) : streamBuilder.GetOptimalVideoStream(options); if (streamInfo is not null) { streamInfo.PlaySessionId = playSessionId; streamInfo.StartPositionTicks = startTimeTicks; mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; // Players do not handle this being set according to PlayMethod mediaSource.SupportsDirectStream = options.EnableDirectStream ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream : streamInfo.PlayMethod == PlayMethod.DirectPlay; mediaSource.SupportsTranscoding = streamInfo.PlayMethod == PlayMethod.DirectStream || mediaSource.TranscodingContainer is not null || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); if (item is Audio) { if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) { mediaSource.SupportsTranscoding = false; } } else if (item is Video) { if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) { mediaSource.SupportsTranscoding = false; } } if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) { mediaSource.SupportsDirectPlay = false; mediaSource.SupportsDirectStream = false; mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), "&allowVideoStreamCopy=false&allowAudioStreamCopy=false"); mediaSource.TranscodingContainer = streamInfo.Container; mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) { mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; } } else { if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) { streamInfo.PlayMethod = PlayMethod.Transcode; mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), null); if (!allowVideoStreamCopy) { mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; } if (!allowAudioStreamCopy) { mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; } if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) { mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true"; } } } // Do this after the above so that StartPositionTicks is set // The token must not be null SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } foreach (var attachment in mediaSource.MediaAttachments) { attachment.DeliveryUrl = string.Format( CultureInfo.InvariantCulture, "/Videos/{0}/{1}/Attachments/{2}", item.Id, mediaSource.Id, attachment.Index); } } /// /// Sort media source. /// /// Playback info response. /// Max bitrate. /// The id of the queried item, whose own media source must stay the default. public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default) { var originalList = result.MediaSources.ToList(); // The queried item's source carries the user's resume state for that version, so it must stay the // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version. var preferredId = preferredItemId.IsEmpty() ? null : preferredItemId.ToString("N", CultureInfo.InvariantCulture); result.MediaSources = result.MediaSources .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase)) .ThenBy(i => { // Nothing beats direct playing a file if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) { return 0; } return 1; }) .ThenBy(i => { // Let's assume direct streaming a file is just as desirable as direct playing a remote url if (i.SupportsDirectPlay || i.SupportsDirectStream) { return 0; } return 1; }) .ThenBy(i => { return i.Protocol switch { MediaProtocol.File => 0, _ => 1, }; }) .ThenBy(i => { if (maxBitrate.HasValue && i.Bitrate.HasValue) { return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; } return 1; }) .ThenBy(originalList.IndexOf) .ToArray(); } /// /// Open media source. /// /// Http Context. /// Live stream request. /// A containing the . public async Task OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) { var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request); var profile = request.DeviceProfile; if (profile is null) { var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); if (clientCapabilities is not null) { profile = clientCapabilities.DeviceProfile; } } if (profile is not null) { var item = _libraryManager.GetItemById(request.ItemId) ?? throw new ResourceNotFoundException(); SetDeviceSpecificData( item, result.MediaSource, profile, httpContext.User, request.MaxStreamingBitrate, request.StartTimeTicks ?? 0, result.MediaSource.Id, request.AudioStreamIndex, request.SubtitleStreamIndex, request.MaxAudioChannels, request.PlaySessionId, request.UserId, request.EnableDirectPlay, request.EnableDirectStream, true, true, true, request.AlwaysBurnInSubtitleWhenTranscoding, httpContext.GetNormalizedRemoteIP()); } else { if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) { result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; } } // here was a check if (result.MediaSource is not null) but Rider said it will never be null NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); return result; } /// /// Normalize media source container. /// /// Media source. /// Device profile. /// Dlna profile type. public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) { mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); } private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) { var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; mediaSource.TranscodeReasons = info.TranscodeReasons; foreach (var profile in profiles) { foreach (var stream in mediaSource.MediaStreams) { if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) { stream.DeliveryMethod = profile.DeliveryMethod; if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) { stream.DeliveryUrl = profile.Url.TrimStart('-'); stream.IsExternalUrl = profile.IsExternalUrl; } } } } } private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) { var maxBitrate = clientMaxBitrate; var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; if (remoteClientMaxBitrate <= 0) { remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; } if (remoteClientMaxBitrate > 0) { var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIP: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); if (!isInLocalNetwork) { maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); } } return maxBitrate; } /// /// Rewrites a Live TV media source's to the request-appropriate published /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local /// addresses. Only opened live streams are eligible. The shared instance held by /// is never touched by this method. /// /// The media source clone to rewrite in place. /// The current . private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request) { // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources. if (string.IsNullOrEmpty(mediaSource.LiveStreamId)) { return; } var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl; var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl); if (publishedPath is not null) { mediaSource.Path = publishedPath; return; } if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id); } } /// /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent. /// Returns null when the path isn't a Jellyfin-hosted /LiveTv/LiveStreamFiles/ HTTP URL. /// /// The request-appropriate base URL, as returned by . /// The media source's local (LAN-access) path, as built from . /// The media source's protocol. /// The server's configured BaseUrl, if any. /// The published path, or null if the local path should be left unchanged. internal static string? GetPublishedLiveStreamPath( string smartApiUrl, string? localPath, MediaProtocol protocol, string baseUrl) { if (protocol != MediaProtocol.Http || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri)) { return null; } var relativePath = localUri.PathAndQuery; if (!string.IsNullOrEmpty(baseUrl)) { var basePrefix = baseUrl + "/"; if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase)) { return null; } relativePath = relativePath[baseUrl.Length..]; } if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase)) { return null; } var prefix = smartApiUrl.TrimEnd('/'); if (!string.IsNullOrEmpty(baseUrl)) { var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri) && Uri.UnescapeDataString(publishedUri.AbsolutePath) .TrimEnd('/') .EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase); if (!includesBaseUrl) { prefix += baseUrl; } } return prefix + relativePath; } }